From 6d14e5c2aa2ce16b45817f2399c0c0f524211d32 Mon Sep 17 00:00:00 2001 From: js0ny Date: Sun, 19 Oct 2025 02:40:19 +0100 Subject: [PATCH] test: Add Test for ApiController --- flake.nix | 40 +++ .../exception/GlobalExceptionHandler.java | 29 ++- .../controller/ApiControllerTest.java | 232 ++++++++++++++++-- .../service/GpsCalculationServiceTest.java | 10 + 4 files changed, 285 insertions(+), 26 deletions(-) create mode 100644 flake.nix diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0c86524 --- /dev/null +++ b/flake.nix @@ -0,0 +1,40 @@ +{ + description = "Flake for environment building ILP CW"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = { + self, + nixpkgs, + }: { + devShells = + nixpkgs.lib.genAttrs [ + "x86_64-linux" + "aarch64-darwin" + ] (system: let + pkgs = import nixpkgs { + inherit system; + }; + in { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + jdk21 + gradle + docker + docker-compose + httpie + podman + podman-compose + newman + ]; + shellHook = '' + export JAVA_HOME=${pkgs.jdk21} + echo "Java: $(java-version | head -n 1)" + echo "Docker: $(docker --version)" + ''; + }; + }); + }; +} diff --git a/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java b/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java index 0d54d23..1134f1e 100644 --- a/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java +++ b/src/main/java/io/github/js0ny/ilp_coursework/exception/GlobalExceptionHandler.java @@ -3,18 +3,29 @@ package io.github.js0ny.ilp_coursework.exception; import java.util.Map; import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * Class that handles exception or failed request. Map all error requests to 400. + */ @RestControllerAdvice public class GlobalExceptionHandler { + + /// Use a logger to save logs instead of passing them to user + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + private final Map badRequestMap = Map.of("status", "400", "error", "Bad Request"); + @ExceptionHandler(HttpMessageNotReadableException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Map handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { - return Map.of("status", "400", "error", "Invalid JSON request body."); + log.warn("Malformed JSON received: {}", ex.getMessage()); + return badRequestMap; } @ExceptionHandler(IllegalArgumentException.class) @@ -22,7 +33,21 @@ public class GlobalExceptionHandler { public Map handleIllegalArgument(IllegalArgumentException ex) { String errorMessage = Optional.ofNullable(ex.getMessage()) .orElse("Invalid argument provided."); - return Map.of("status", "400", "error", errorMessage); + log.warn("Illegal argument in request: {}", errorMessage); + return badRequestMap; } + @ExceptionHandler(NullPointerException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleNullPointerException(Exception ex) { + log.error("NullPointerException occurred. Return 400 by default.", ex); + return badRequestMap; + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleGeneralException(Exception ex) { + log.error("Fallback exception received: {}", ex.getMessage()); + return badRequestMap; + } } diff --git a/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java b/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java index a82cb84..9339c86 100644 --- a/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java +++ b/src/test/java/io/github/js0ny/ilp_coursework/controller/ApiControllerTest.java @@ -1,20 +1,28 @@ package io.github.js0ny.ilp_coursework.controller; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.js0ny.ilp_coursework.data.DistanceRequestDto; -import io.github.js0ny.ilp_coursework.data.LngLatDto; +import io.github.js0ny.ilp_coursework.data.*; import io.github.js0ny.ilp_coursework.service.GpsCalculationService; +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.autoconfigure.orm.jpa.EntityManagerFactoryDependsOnPostProcessor; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import java.util.List; + @WebMvcTest(ApiController.class) public class ApiControllerTest { @@ -25,31 +33,207 @@ public class ApiControllerTest { private ObjectMapper objectMapper; @MockitoBean - private GpsCalculationService gpsCalculationService; + private GpsCalculationService service; - @Test - void getUid_shouldReturnStudentIdFromService() throws Exception { - String endpoint = "/api/v1/uid"; - String expected = "s2522255"; - var mock = mockMvc.perform(get(endpoint)); - mock.andExpect(MockMvcResultMatchers.status().isOk()); - mock.andExpect(MockMvcResultMatchers.content().string(expected)); + @Nested + @DisplayName("GET /uid") + class GetUidTests { + @Test + @DisplayName("GET /uid -> 200 OK") + void getUid_shouldReturn200AndStudentIdFromService() throws Exception { + String endpoint = "/api/v1/uid"; + String expected = "s2522255"; + var mock = mockMvc.perform(get(endpoint)); + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(expected)); + } } - @Test - void getDistance_shouldReturnDoubleFromService_whenCorrectInput() throws Exception { - double expected = 5.0; - String endpoint = "/api/v1/distanceTo"; - LngLatDto p1 = new LngLatDto(0, 4.0); - LngLatDto p2 = new LngLatDto(3.0, 0); - var req = new DistanceRequestDto(p1, p2); - var mock = mockMvc.perform( - post(endpoint) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(req)) - ); + @Nested + @DisplayName("POST /distanceTo") + class GetDistanceTests { + @Test + @DisplayName("POST /distanceTo -> 200 OK") + void getDistance_shouldReturn200AndDistance_whenCorrectInput() throws Exception { + double expected = 5.0; + String endpoint = "/api/v1/distanceTo"; + LngLatDto p1 = new LngLatDto(0, 4.0); + LngLatDto p2 = new LngLatDto(3.0, 0); + var req = new DistanceRequestDto(p1, p2); + when(service.calculateDistance(any(LngLatDto.class), any(LngLatDto.class))).thenReturn(expected); + var mock = mockMvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))); + + + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(String.valueOf(expected))); + } + + @Test + @DisplayName("POST /distanceTo -> 400 Bad Request: Missing Field") + void getDistance_shouldReturn400_whenMissingField() throws Exception { + double expected = 5.0; + String endpoint = "/api/v1/distanceTo"; + String req = """ + { + "position1": { + "lng": 3.0, + "lat": 4.0 + } + } + """; + when(service.calculateDistance(any(LngLatDto.class), isNull())).thenThrow(new NullPointerException()); + var mock = mockMvc.perform(post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(req)) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + } - mock.andExpect(MockMvcResultMatchers.status().isOk()); - mock.andExpect(MockMvcResultMatchers.content().string(String.valueOf(expected))); } + + @Nested + @DisplayName("POST /isCloseTo") + class IsCloseToTests { + @Test + @DisplayName("POST /isCloseTo -> 200 OK") + void getIsCloseTo_shouldReturn200AndBoolean_whenCorrectInput() throws Exception { + boolean expected = false; + String endpoint = "/api/v1/isCloseTo"; + LngLatDto p1 = new LngLatDto(0, 4.0); + LngLatDto p2 = new LngLatDto(3.0, 0); + var req = new DistanceRequestDto(p1, p2); + when(service.isCloseTo(any(LngLatDto.class), any(LngLatDto.class))).thenReturn(expected); + var mock = mockMvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))); + + + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(String.valueOf(expected))); + } + + @Test + @DisplayName("POST /isCloseTo -> 400 Bad Request: Malformed JSON ") + void getIsCloseTo_shouldReturn400_whenJsonIsMalformed() throws Exception { + // json without a bracket + String malformedJson = """ + { + "position1": { "lng": 0.0, "lat": 3.0 } + """; + mockMvc.perform(post("/api/v1/isCloseTo") + .contentType(MediaType.APPLICATION_JSON) + .content(malformedJson)) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + + } + } + + @Nested + @DisplayName("POST /nextPosition") + class GetNextPositionTests { + String endpoint = "/api/v1/nextPosition"; + + @Test + @DisplayName("POST /nextPosition -> 200 OK") + void getNextPosition_shouldReturn200AndCoordinate_whenCorrectInput() throws Exception { + LngLatDto expected = new LngLatDto(0.00015, 0.0); + LngLatDto p = new LngLatDto(0, 0); + var req = new MovementRequestDto(p, 0); + when(service.nextPosition(any(LngLatDto.class), anyDouble())).thenReturn(expected); + var mock = mockMvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))); + + + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().json( + objectMapper.writeValueAsString(expected))); + + } + + @Test + @DisplayName("POST /nextPosition -> 400 Bad Request: Missing Field") + void getNextPosition_shouldReturn400_whenKeyNameError() throws Exception { + // "position" should be "start" + String malformedJson = """ + { + "position": { "lng": 0.0, "lat": 3.0 }, + "angle": 180 + } + """; + when(service.nextPosition(isNull(), anyDouble())).thenThrow(new NullPointerException()); + mockMvc.perform(post("/api/v1/nextPosition") + .contentType(MediaType.APPLICATION_JSON) + .content(malformedJson)) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + } + + + @Nested + @DisplayName("POST /isInRegion") + class GetIsInRegionTests { + @Test + @DisplayName("POST /isInRegion -> 200 OK") + void getIsInRegion_shouldReturn200AndBoolean_whenCorrectInput() throws Exception { + boolean expected = false; + String endpoint = "/api/v1/isInRegion"; + var position = new LngLatDto(1.234, 1.222); + var region = new RegionDto("central", + List.of(new LngLatDto(-3.192473, 55.946233), new LngLatDto(-3.192473, 55.942617), + new LngLatDto(-3.184319, 55.942617), new LngLatDto(-3.184319, 55.946233), + new LngLatDto(-3.192473, 55.946233))); + var req = new RegionCheckRequestDto(position, region); + when(service.checkIsInRegion(any(LngLatDto.class), any(RegionDto.class))).thenReturn(expected); + var mock = mockMvc.perform( + post(endpoint) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))); + + + mock.andExpect(MockMvcResultMatchers.status().isOk()); + mock.andExpect(MockMvcResultMatchers.content().string(String.valueOf(expected))); + } + + @Test + @DisplayName("POST /isInRegion -> 400 Bad Request: Passing a list of empty vertices to isInRegion") + void getIsInRegion_shouldReturn400_whenPassingIllegalArguments() throws Exception { + var position = new LngLatDto(1, 1); + var region = new RegionDto("illegal", List.of()); + var request = new RegionCheckRequestDto(position, region); + when(service.checkIsInRegion(any(LngLatDto.class), any(RegionDto.class))) + .thenThrow(new IllegalArgumentException("Region is not closed.")); + mockMvc.perform(post("/api/v1/isInRegion") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + + @Test + @DisplayName("POST /isInRegion -> 400 Bad Request: Passing a list of not-closing vertices to isInRegion") + void getIsInRegion_shouldReturn400_whenPassingNotClosingVertices() throws Exception { + var position = new LngLatDto(1, 1); + var region = new RegionDto("illegal", List.of( + new LngLatDto(1, 2), + new LngLatDto(3, 4), + new LngLatDto(5, 6), + new LngLatDto(7, 8), + new LngLatDto(9, 10) + )); + var request = new RegionCheckRequestDto(position, region); + when(service.checkIsInRegion(any(LngLatDto.class), any(RegionDto.class))) + .thenThrow(new IllegalArgumentException("Region is not closed.")); + mockMvc.perform(post("/api/v1/isInRegion") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(MockMvcResultMatchers.status().isBadRequest()); + } + } + + } diff --git a/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java b/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java index 3f8bc81..49e74c3 100644 --- a/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java +++ b/src/test/java/io/github/js0ny/ilp_coursework/service/GpsCalculationServiceTest.java @@ -382,5 +382,15 @@ public class GpsCalculationServiceTest { service.checkIsInRegion(position, region); }).isInstanceOf(IllegalArgumentException.class).hasMessage("Region is not closed."); } + + @Test + @DisplayName("Edge Case: Vertex list is empty") + void isInRegion_shouldThrowExceptions_whenListIsEmpty() { + var position = new LngLatDto(2.0, 2.0); + var region = new RegionDto("rectangle", List.of()); + assertThatThrownBy(() -> { + service.checkIsInRegion(position, region); + }).isInstanceOf(IllegalArgumentException.class).hasMessage("Region is not closed."); + } } }