diff --git a/src/main/java/com/shipping/freightops/controller/ContainerController.java b/src/main/java/com/shipping/freightops/controller/ContainerController.java index e89df4a..6e3bec7 100644 --- a/src/main/java/com/shipping/freightops/controller/ContainerController.java +++ b/src/main/java/com/shipping/freightops/controller/ContainerController.java @@ -1,17 +1,22 @@ package com.shipping.freightops.controller; import com.shipping.freightops.dto.ContainerLabelResponse; +import com.shipping.freightops.dto.ContainerResponse; +import com.shipping.freightops.dto.CreateContainerRequest; +import com.shipping.freightops.entity.Container; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; import com.shipping.freightops.service.ContainerService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/containers") @@ -23,6 +28,46 @@ public ContainerController(ContainerService containerService) { this.containerService = containerService; } + /** Register a new container. */ + @Operation(summary = "Register a new container") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Container successfully created"), + @ApiResponse(responseCode = "400", description = "Invalid request data"), + @ApiResponse(responseCode = "409", description = "Container code already exists") + }) + @PostMapping + public ResponseEntity create( + @Valid @RequestBody CreateContainerRequest request) { + Container container = containerService.createContainer(request); + ContainerResponse body = ContainerResponse.fromEntity(container); + URI location = URI.create("/api/v1/containers/" + container.getId()); + return ResponseEntity.created(location).body(body); + } + + /** List all containers, optionally filtered by size and/or type. */ + @Operation(summary = "List all containers with optional size and type filters") + @ApiResponses({@ApiResponse(responseCode = "200", description = "List of containers returned")}) + @GetMapping + public ResponseEntity> list( + @RequestParam(required = false) ContainerSize size, + @RequestParam(required = false) ContainerType type) { + List containers = containerService.getAllContainers(size, type); + List body = containers.stream().map(ContainerResponse::fromEntity).toList(); + return ResponseEntity.ok(body); + } + + /** Get a single container by ID. */ + @Operation(summary = "Get a container by ID") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Container found"), + @ApiResponse(responseCode = "404", description = "Container not found") + }) + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id) { + Container container = containerService.getContainerById(id); + return ResponseEntity.ok(ContainerResponse.fromEntity(container)); + } + @Operation( summary = "Get PDF label for a container by ID", description = "Returns a PDF document containing the container label") diff --git a/src/main/java/com/shipping/freightops/dto/ContainerResponse.java b/src/main/java/com/shipping/freightops/dto/ContainerResponse.java new file mode 100644 index 0000000..9bbc1bd --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/ContainerResponse.java @@ -0,0 +1,53 @@ +package com.shipping.freightops.dto; + +import com.shipping.freightops.entity.Container; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; +import java.time.LocalDateTime; + +/** Read-only view of a container returned by the API. */ +public class ContainerResponse { + + private Long id; + private String containerCode; + private ContainerSize size; + private ContainerType type; + private int teu; + private LocalDateTime createdAt; + + /** Factory method to map entity → response DTO. */ + public static ContainerResponse fromEntity(Container container) { + ContainerResponse dto = new ContainerResponse(); + dto.id = container.getId(); + dto.containerCode = container.getContainerCode(); + dto.size = container.getSize(); + dto.type = container.getType(); + dto.teu = container.getTeu(); + dto.createdAt = container.getCreatedAt(); + return dto; + } + + public Long getId() { + return id; + } + + public String getContainerCode() { + return containerCode; + } + + public ContainerSize getSize() { + return size; + } + + public ContainerType getType() { + return type; + } + + public int getTeu() { + return teu; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/shipping/freightops/dto/CreateContainerRequest.java b/src/main/java/com/shipping/freightops/dto/CreateContainerRequest.java new file mode 100644 index 0000000..2c350cb --- /dev/null +++ b/src/main/java/com/shipping/freightops/dto/CreateContainerRequest.java @@ -0,0 +1,46 @@ +package com.shipping.freightops.dto; + +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public class CreateContainerRequest { + + @NotBlank(message = "Container code is required") + @Size(min = 11, max = 11, message = "Container code must be exactly 11 characters") + private String containerCode; + + /** Must be one of: TWENTY_FOOT, FORTY_FOOT. */ + @NotNull(message = "Container size is required") + private ContainerSize size; + + /** Must be one of: DRY, REEFER, OPEN_TOP, FLAT_RACK, TANK. */ + @NotNull(message = "Container type is required") + private ContainerType type; + + public String getContainerCode() { + return containerCode; + } + + public void setContainerCode(String containerCode) { + this.containerCode = containerCode; + } + + public ContainerSize getSize() { + return size; + } + + public void setSize(ContainerSize size) { + this.size = size; + } + + public ContainerType getType() { + return type; + } + + public void setType(ContainerType type) { + this.type = type; + } +} diff --git a/src/main/java/com/shipping/freightops/repository/ContainerRepository.java b/src/main/java/com/shipping/freightops/repository/ContainerRepository.java index fa82b5a..000d896 100644 --- a/src/main/java/com/shipping/freightops/repository/ContainerRepository.java +++ b/src/main/java/com/shipping/freightops/repository/ContainerRepository.java @@ -1,10 +1,21 @@ package com.shipping.freightops.repository; import com.shipping.freightops.entity.Container; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ContainerRepository extends JpaRepository { Optional findByContainerCode(String containerCode); + + boolean existsByContainerCode(String containerCode); + + List findBySize(ContainerSize size); + + List findByType(ContainerType type); + + List findBySizeAndType(ContainerSize size, ContainerType type); } diff --git a/src/main/java/com/shipping/freightops/service/ContainerService.java b/src/main/java/com/shipping/freightops/service/ContainerService.java index a2f0349..edaeaad 100644 --- a/src/main/java/com/shipping/freightops/service/ContainerService.java +++ b/src/main/java/com/shipping/freightops/service/ContainerService.java @@ -6,8 +6,11 @@ import com.itextpdf.text.pdf.PdfWriter; import com.shipping.freightops.config.AppProperties; import com.shipping.freightops.dto.ContainerLabelResponse; +import com.shipping.freightops.dto.CreateContainerRequest; import com.shipping.freightops.entity.Container; import com.shipping.freightops.entity.FreightOrder; +import com.shipping.freightops.enums.ContainerSize; +import com.shipping.freightops.enums.ContainerType; import com.shipping.freightops.enums.OrderStatus; import com.shipping.freightops.exception.PdfGenerationException; import com.shipping.freightops.repository.ContainerRepository; @@ -15,6 +18,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.format.DateTimeFormatter; +import java.util.List; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -57,6 +61,39 @@ public ContainerService( this.appProperties = appProperties; } + @Transactional + public Container createContainer(CreateContainerRequest request) { + if (containerRepository.existsByContainerCode(request.getContainerCode())) { + throw new IllegalStateException( + "Container code already exists: " + request.getContainerCode()); + } + + Container container = + new Container(request.getContainerCode(), request.getSize(), request.getType()); + return containerRepository.save(container); + } + + @Transactional(readOnly = true) + public List getAllContainers(ContainerSize size, ContainerType type) { + if (size != null && type != null) { + return containerRepository.findBySizeAndType(size, type); + } + if (size != null) { + return containerRepository.findBySize(size); + } + if (type != null) { + return containerRepository.findByType(type); + } + return containerRepository.findAll(); + } + + @Transactional(readOnly = true) + public Container getContainerById(Long id) { + return containerRepository + .findById(id) + .orElseThrow(() -> new IllegalArgumentException("Container not found: " + id)); + } + @Transactional(readOnly = true) public ContainerLabelResponse generateContainerLabel(long containerId) { diff --git a/src/test/java/com/shipping/freightops/controller/ContainerControllerTest.java b/src/test/java/com/shipping/freightops/controller/ContainerControllerTest.java index efcac3c..750f56a 100644 --- a/src/test/java/com/shipping/freightops/controller/ContainerControllerTest.java +++ b/src/test/java/com/shipping/freightops/controller/ContainerControllerTest.java @@ -1,10 +1,20 @@ package com.shipping.freightops.controller; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.everyItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isA; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.shipping.freightops.dto.CreateContainerRequest; import com.shipping.freightops.entity.*; import com.shipping.freightops.enums.AgentType; import com.shipping.freightops.enums.ContainerSize; @@ -31,6 +41,7 @@ @Transactional public class ContainerControllerTest { @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; @Autowired private ContainerRepository containerRepository; @Autowired private VoyageRepository voyageRepository; @Autowired private VesselRepository vesselRepository; @@ -41,18 +52,29 @@ public class ContainerControllerTest { private Container savedContainer; private Voyage activeVoyage; - private Voyage historicalVoyage; private Customer savedCustomer; private Agent savedAgent; @BeforeEach void setUp() { + // Clean up existing containers to avoid test interference + containerRepository.deleteAll(); - // Container + // Container for PDF label test and other operations savedContainer = containerRepository.save( new Container("TSTU1234567", ContainerSize.TWENTY_FOOT, ContainerType.DRY)); + // Additional containers for filter testing + containerRepository.save( + new Container("ABCD1234567", ContainerSize.TWENTY_FOOT, ContainerType.DRY)); + + containerRepository.save( + new Container("EFGH7654321", ContainerSize.FORTY_FOOT, ContainerType.DRY)); + + containerRepository.save( + new Container("IJKL9876543", ContainerSize.FORTY_FOOT, ContainerType.REEFER)); + // Ports Port departure = portRepository.save(new Port("AEJEK", "Jebel Ali", "UAE")); Port arrival = portRepository.save(new Port("CNSHK", "Shanghai", "China")); @@ -122,10 +144,144 @@ void setUp() { freightOrderRepository.save(activeOrder); } + @Test + @DisplayName("POST /api/v1/containers → 201 Created") + void createContainer_returnsCreated() throws Exception { + CreateContainerRequest request = new CreateContainerRequest(); + request.setContainerCode("WXYZ9876543"); + request.setSize(ContainerSize.TWENTY_FOOT); + request.setType(ContainerType.DRY); + + mockMvc + .perform( + post("/api/v1/containers") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", containsString("/api/v1/containers/"))) + .andExpect(jsonPath("$.containerCode").value("WXYZ9876543")) + .andExpect(jsonPath("$.size").value("TWENTY_FOOT")) + .andExpect(jsonPath("$.type").value("DRY")) + .andExpect(jsonPath("$.id").exists()); + } + + @Test + @DisplayName("POST /api/v1/containers with existing containerCode → 409 Conflict") + void createContainer_withDuplicateCode_returnsConflict() throws Exception { + CreateContainerRequest request = new CreateContainerRequest(); + request.setContainerCode("TSTU1234567"); // Same as savedContainer + request.setSize(ContainerSize.TWENTY_FOOT); + request.setType(ContainerType.DRY); + + mockMvc + .perform( + post("/api/v1/containers") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("POST /api/v1/containers with invalid enum value → 400 Bad Request") + void createContainer_withInvalidEnum_returnsBadRequest() throws Exception { + String requestWithInvalidEnum = + "{\"containerCode\":\"WXYZ9876543\",\"size\":\"INVALID_SIZE\",\"type\":\"DRY\"}"; + + mockMvc + .perform( + post("/api/v1/containers") + .contentType(MediaType.APPLICATION_JSON) + .content(requestWithInvalidEnum)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("POST /api/v1/containers with invalid containerCode format → 400 Bad Request") + void createContainer_withInvalidContainerCode_returnsBadRequest() throws Exception { + CreateContainerRequest request = new CreateContainerRequest(); + request.setContainerCode("INVALID"); // Too short, wrong format + request.setSize(ContainerSize.TWENTY_FOOT); + request.setType(ContainerType.DRY); + + mockMvc + .perform( + post("/api/v1/containers") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("GET /api/v1/containers → 200 OK with all containers") + void getAllContainers_returnsOk() throws Exception { + mockMvc + .perform(get("/api/v1/containers")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", isA(java.util.List.class))) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$", hasSize(4))); // Exactly 4 containers from setup + } + + @Test + @DisplayName("GET /api/v1/containers?size=TWENTY_FOOT → 200 OK with filtered containers") + void getAllContainersFilteredBySize_returnsOk() throws Exception { + mockMvc + .perform(get("/api/v1/containers").param("size", "TWENTY_FOOT")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$", hasSize(2))) // Exactly 2 TWENTY_FOOT containers from setup + .andExpect(jsonPath("$[0].size").value("TWENTY_FOOT")) + .andExpect(jsonPath("$[1].size").value("TWENTY_FOOT")); + } + + @Test + @DisplayName("GET /api/v1/containers?type=REEFER → 200 OK with filtered containers") + void getAllContainersFilteredByType_returnsOk() throws Exception { + mockMvc + .perform(get("/api/v1/containers").param("type", "REEFER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$", hasSize(1))) // Exactly 1 REEFER container + .andExpect(jsonPath("$[*].type", everyItem(is("REEFER")))); + } + + @Test + @DisplayName("GET /api/v1/containers with invalid enum parameter → returns error") + void getAllContainers_withInvalidEnum_returnsError() throws Exception { + mockMvc + .perform(get("/api/v1/containers").param("size", "INVALID_SIZE")) + .andExpect( + result -> { + // Spring's behavior depends on configuration - it should reject invalid enums + // with either 400 (Bad Request) or 404 (Not Found) + int status = result.getResponse().getStatus(); + if (status != 400 && status != 404) { + throw new AssertionError("Expected status 400 or 404 but got " + status); + } + }); + } + + @Test + @DisplayName("GET /api/v1/containers/{id} → 200 OK with container") + void getContainerById_returnsOk() throws Exception { + mockMvc + .perform(get("/api/v1/containers/{id}", savedContainer.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(savedContainer.getId())) + .andExpect(jsonPath("$.containerCode").value("TSTU1234567")) + .andExpect(jsonPath("$.size").value("TWENTY_FOOT")) + .andExpect(jsonPath("$.type").value("DRY")); + } + + @Test + @DisplayName("GET /api/v1/containers/{id} with invalid ID → 404 Not Found") + void getContainerById_withInvalidId_returnsNotFound() throws Exception { + mockMvc.perform(get("/api/v1/containers/{id}", 99999L)).andExpect(status().isNotFound()); + } + @Test @DisplayName("GET /api/v1/containers/{id}/label → returns valid PDF with voyage info") void getContainerLabel_returnsValidPdf() throws Exception { - mockMvc .perform(get("/api/v1/containers/{id}/label", savedContainer.getId())) .andExpect(status().isOk())