diff --git a/.bin/e2e.sh b/.bin/e2e.sh new file mode 100644 index 0000000..4e3f8e5 --- /dev/null +++ b/.bin/e2e.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -e + + + +echo "[INFO] Starting Go Backend..." +cd drone-black-box && go build -o app main.go +cd .. + +./drone-black-box/app & +GO_PID=$! +echo " Go PID: $GO_PID" + +echo "[INFO] Starting Java Backend..." + +cd ilp-rest-service +./gradlew bootRun & +JAVA_PID=$! +echo " Java PID: $JAVA_PID" +cd .. + +cleanup() { + echo "[INFO] Stopping services..." + kill $GO_PID || true + kill $JAVA_PID || true +} +trap cleanup EXIT + + +echo "[INFO] Waiting for services to be ready..." + +for i in {1..30}; do + if curl -s http://localhost:8080/actuator/health > /dev/null; then + echo "[INFO] Java is UP!" + break + fi + echo "[DEBUG] Waiting for Java..." + sleep 2 +done + + +echo "[INFO] Running Bruno E2E Collection..." + +cd ./ilp-rest-service/ilp-cw-api + +bru run + +echo "[INFO] E2E Tests Passed!" + +rm --force drone_black_box.db +rm --force drone_black_box.db-wal +rm --force drone_black_box.db-shm + +cleanup diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..16ff453 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: Polyglot CI + +on: + push: + branches: [ "main" ] + paths: + - '**.java' + - '**.go' + - '.justfile' + - '**.js' + - '**.svelte' + - '**.css' + pull_request: + branches: [ "main" ] + + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v25 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@v14 + with: + name: mycache + - run: chmod +x ./ilp-rest-service/gradlew + - name: Unit Testing and Integration Testing + run: nix develop .#ci --command just all + - name: E2E Test + run: nix develop .#ci --command just e2e diff --git a/.gitignore b/.gitignore index e8e3c27..55bcae1 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,9 @@ drone-black-box/drone-black-box drone-black-box/drone_black_box.db* *.pdf + +*.out +*.html + +drone_black_box.db* +app diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..15b37ae --- /dev/null +++ b/.justfile @@ -0,0 +1,29 @@ +all: + @just format + @just static-analysis + @just test + +format: + cd ./drone-black-box && go fmt + fd --extension java --exec google-java-format --replace --aosp {} + + +static-analysis: + cd ./drone-black-box && go vet ./... + +test: + cd ./drone-black-box && go test -v ./... -coverprofile=coverage.out + cd ./drone-black-box && go tool cover -html=coverage.out -o coverage.html + rm ./drone-black-box/coverage.out + # Java + cd ./ilp-rest-service/ && ./gradlew check + +e2e: + bash .bin/e2e.sh + +benchmark: + echo "Make sure the server is running at http://localhost:3000" + oha -z 10s -c 50 -m POST \ + -H "Content-Type: application/json" \ + -d '{"drone_id": "TEST-DRONE", "latitude": 0.0, "longitude": 0.0, "timestamp": "2025-01-01"}' \ + http://localhost:3000/ingest diff --git a/README.md b/README.md index 17b974d..54acbad 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Informatics Large Practical Coursework 3 +This is a temporary repository, if you are viewing on GitHub, just ignore the (randomly generated) repository name. + +[![Polyglot CI](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml/badge.svg)](https://github.com/js0ny/expert-goggles/actions/workflows/ci.yml) + ## Installation ### Docker Compose diff --git a/drone-black-box/main_test.go b/drone-black-box/main_test.go index 6fc24b0..b6537e9 100644 --- a/drone-black-box/main_test.go +++ b/drone-black-box/main_test.go @@ -125,6 +125,7 @@ func TestIngestBadJSON(t *testing.T) { // Ensure graceful shutdown path does not hang: start a server and shut it down quickly. func TestGracefulShutdown(t *testing.T) { db := newTestDB(t) + defer db.Close() srv := &Server{db: db} mux := http.NewServeMux() mux.HandleFunc("GET /health", srv.healthHandler) @@ -138,3 +139,75 @@ func TestGracefulShutdown(t *testing.T) { t.Fatalf("shutdown: %v", err) } } + +func TestCORSAllowedOriginAndPreflight(t *testing.T) { + handled := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handled = true + w.WriteHeader(http.StatusOK) + }) + handler := corsMiddleware(next) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("Origin", "http://localhost:5173") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" { + t.Fatalf("expected allow-origin header, got %q", got) + } + if !handled { + t.Fatalf("expected handler to be called") + } + + preflight := httptest.NewRequest(http.MethodOptions, "/health", nil) + preflight.Header.Set("Origin", "http://localhost:5173") + preflightRec := httptest.NewRecorder() + handler.ServeHTTP(preflightRec, preflight) + if preflightRec.Code != http.StatusNoContent { + t.Fatalf("preflight expected 204, got %d", preflightRec.Code) + } +} + +func TestIngestDBError(t *testing.T) { + db := newTestDB(t) + db.Close() + srv := &Server{db: db} + + ev := DroneEvent{DroneID: "d1", Latitude: 1.0, Longitude: 2.0, Timestamp: "2025-12-06T00:00:00Z"} + body, _ := json.Marshal(ev) + req := httptest.NewRequest(http.MethodPost, "/ingest", bytes.NewReader(body)) + rec := httptest.NewRecorder() + srv.ingestHandler(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} + +func TestSnapshotQueryError(t *testing.T) { + db := newTestDB(t) + db.Close() + srv := &Server{db: db} + + req := httptest.NewRequest(http.MethodGet, "/snapshot?time=2025-12-06T00:00:00Z", nil) + rec := httptest.NewRecorder() + srv.snapshotHandler(rec, req) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", rec.Code) + } +} + +func TestHealthFailure(t *testing.T) { + db := newTestDB(t) + db.Close() + srv := &Server{db: db} + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + srv.healthHandler(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} diff --git a/flake.nix b/flake.nix index 596f994..30c3f5d 100644 --- a/flake.nix +++ b/flake.nix @@ -17,29 +17,42 @@ pkgs = import nixpkgs { inherit system; }; + ciDeps = with pkgs; [ + jdk21 + gradle + google-java-format + go + just + fd + bruno-cli + ]; + devDeps = with pkgs; [ + vscode-langservers-extracted + jdt-language-server + jless + httpie + docker + docker-compose + newman + gron + fx + oha + gopls + bun + svelte-language-server + typescript-language-server + prettier + ]; in { default = pkgs.mkShell { - buildInputs = with pkgs; [ - vscode-langservers-extracted - jdt-language-server - jless - jdk21 - gradle - httpie - docker - docker-compose - newman - gron - fx - google-java-format - oha - gopls - go - bun - svelte-language-server - typescript-language-server - prettier - ]; + buildInputs = ciDeps ++ devDeps; + shellHook = '' + export JAVA_HOME=${pkgs.jdk21} + echo "Java: $(java --version | head -n 1)" + ''; + }; + ci = pkgs.mkShell { + buildInputs = ciDeps; shellHook = '' export JAVA_HOME=${pkgs.jdk21} echo "Java: $(java --version | head -n 1)" diff --git a/ilp-rest-service/build.gradle b/ilp-rest-service/build.gradle index 81be9e6..6cdeedb 100644 --- a/ilp-rest-service/build.gradle +++ b/ilp-rest-service/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.5.6' id 'io.spring.dependency-management' version '1.1.7' + id 'jacoco' } group = 'io.github.js0ny' @@ -26,6 +27,58 @@ dependencies { } +jacoco { + toolVersion = "0.8.12" +} + tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport } + +jacocoTestReport { + dependsOn test + + reports { + xml.required = true + html.required = true + } + + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/IlpCourseworkApplication.class', + '**/config/*', + '**/data/*', + '**/util/*', + '**/TelemetryService.class' + ]) + })) + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + element = 'CLASS' + + excludes = [ + 'io.github.js0ny.ilp_coursework.IlpCourseworkApplication', + '**.config.**', + '**.data.**', + '**.util.**', + 'io.github.js0ny.ilp_coursework.service.TelemetryService' + ] + + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + + minimum = 0.50 + } + } + } +} + + +check.dependsOn jacocoTestCoverageVerification diff --git a/ilp-rest-service/ilp-cw-api/Front-end Test/2 Drones.bru b/ilp-rest-service/ilp-cw-api/Front-end Test/2 Drones.bru index 072c15f..d31d50a 100644 --- a/ilp-rest-service/ilp-cw-api/Front-end Test/2 Drones.bru +++ b/ilp-rest-service/ilp-cw-api/Front-end Test/2 Drones.bru @@ -1,5 +1,5 @@ meta { - name: 2 Drones + name: 2 Drones type: http seq: 9 } @@ -14,7 +14,7 @@ body:json { [ { "id": 123, - "date": "2025-12-22", + "date": "2025-12-25", "time": "14:30", "requirements": { "capacity": 0.75, @@ -28,7 +28,7 @@ body:json { }, { "id": 456, - "date": "2025-12-25", + "date": "2025-12-23", "time": "11:30", "requirements": { "capacity": 0.75, diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/config/CorsConfig.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/config/CorsConfig.java index d8511e5..63a949d 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/config/CorsConfig.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/config/CorsConfig.java @@ -4,18 +4,17 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -/** - * Global CORS configuration so the frontend running on a different port can call the REST API. - */ +/** Global CORS configuration so the frontend running on a different port can call the REST API. */ @Configuration public class CorsConfig implements WebMvcConfigurer { - private static final String[] ALLOWED_ORIGINS = new String[] { - "http://localhost:4173", - "http://127.0.0.1:4173", - "http://localhost:5173", - "http://127.0.0.1:5173" - }; + private static final String[] ALLOWED_ORIGINS = + new String[] { + "http://localhost:4173", + "http://127.0.0.1:4173", + "http://localhost:5173", + "http://127.0.0.1:5173" + }; @Override public void addCorsMappings(CorsRegistry registry) { diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java index dbd2147..2884dd4 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/ApiController.java @@ -8,6 +8,8 @@ import io.github.js0ny.ilp_coursework.data.request.MovementRequest; import io.github.js0ny.ilp_coursework.data.request.RegionCheckRequest; import io.github.js0ny.ilp_coursework.service.GpsCalculationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -25,6 +27,8 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/v1") public class ApiController { + private static final Logger log = LoggerFactory.getLogger(ApiController.class); + private final GpsCalculationService gpsService; /** @@ -45,6 +49,7 @@ public class ApiController { */ @GetMapping("/uid") public String getUid() { + log.info("GET /api/v1/uid"); return "s2522255"; } @@ -58,6 +63,7 @@ public class ApiController { public double getDistance(@RequestBody DistanceRequest request) { LngLat position1 = request.position1(); LngLat position2 = request.position2(); + log.info("POST /api/v1/distanceTo position1={} position2={}", position1, position2); return gpsService.calculateDistance(position1, position2); } @@ -72,6 +78,7 @@ public class ApiController { public boolean getIsCloseTo(@RequestBody DistanceRequest request) { LngLat position1 = request.position1(); LngLat position2 = request.position2(); + log.info("POST /api/v1/isCloseTo position1={} position2={}", position1, position2); return gpsService.isCloseTo(position1, position2); } @@ -86,6 +93,7 @@ public class ApiController { public LngLat getNextPosition(@RequestBody MovementRequest request) { LngLat start = request.start(); Angle angle = new Angle(request.angle()); + log.info("POST /api/v1/nextPosition start={} angle={}", start, angle); return gpsService.nextPosition(start, angle); } @@ -99,6 +107,7 @@ public class ApiController { public boolean getIsInRegion(@RequestBody RegionCheckRequest request) { LngLat position = request.position(); Region region = request.region(); + log.info("POST /api/v1/isInRegion position={} region={}", position, region); return gpsService.checkIsInRegion(position, region); } } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java index 712fa8d..cd6d3f4 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/DroneController.java @@ -8,6 +8,8 @@ import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService; import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.PathFinderService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -23,6 +25,8 @@ import java.util.List; @RequestMapping("/api/v1") public class DroneController { + private static final Logger log = LoggerFactory.getLogger(DroneController.class); + private final DroneInfoService droneInfoService; private final DroneAttrComparatorService droneAttrComparatorService; private final PathFinderService pathFinderService; @@ -55,6 +59,7 @@ public class DroneController { */ @GetMapping("/dronesWithCooling/{state}") public List getDronesWithCoolingCapability(@PathVariable boolean state) { + log.info("GET /api/v1/dronesWithCooling/{}", state); return droneInfoService.dronesWithCooling(state); } @@ -68,9 +73,11 @@ public class DroneController { @GetMapping("/droneDetails/{id}") public ResponseEntity getDroneDetail(@PathVariable String id) { try { + log.info("GET /api/v1/droneDetails/{}", id); Drone drone = droneInfoService.droneDetail(id); return ResponseEntity.ok(drone); } catch (IllegalArgumentException ex) { + log.warn("GET /api/v1/droneDetails/{} not found", id); return ResponseEntity.notFound().build(); } } @@ -86,26 +93,35 @@ public class DroneController { @GetMapping("/queryAsPath/{attrName}/{attrVal}") public List getIdByAttrMap( @PathVariable String attrName, @PathVariable String attrVal) { + log.info("GET /api/v1/queryAsPath/{}/{}", attrName, attrVal); return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal); } @PostMapping("/query") public List getIdByAttrMapPost(@RequestBody AttrQueryRequest[] attrComparators) { + int count = attrComparators == null ? 0 : attrComparators.length; + log.info("POST /api/v1/query comparators={}", count); return droneAttrComparatorService.dronesSatisfyingAttributes(attrComparators); } @PostMapping("/queryAvailableDrones") public List queryAvailableDrones(@RequestBody MedDispatchRecRequest[] records) { + int count = records == null ? 0 : records.length; + log.info("POST /api/v1/queryAvailableDrones records={}", count); return droneInfoService.dronesMatchesRequirements(records); } @PostMapping("/calcDeliveryPath") public DeliveryPathResponse calculateDeliveryPath(@RequestBody MedDispatchRecRequest[] record) { + int count = record == null ? 0 : record.length; + log.info("POST /api/v1/calcDeliveryPath records={}", count); return pathFinderService.calculateDeliveryPath(record); } @PostMapping("/calcDeliveryPathAsGeoJson") public String calculateDeliveryPathAsGeoJson(@RequestBody MedDispatchRecRequest[] record) { + int count = record == null ? 0 : record.length; + log.info("POST /api/v1/calcDeliveryPathAsGeoJson records={}", count); return pathFinderService.calculateDeliveryPathAsGeoJson(record); } } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/MapMetaController.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/MapMetaController.java index d4c81b9..be33787 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/MapMetaController.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/controller/MapMetaController.java @@ -1,19 +1,23 @@ package io.github.js0ny.ilp_coursework.controller; -import java.util.List; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; import io.github.js0ny.ilp_coursework.data.external.ServicePoint; import io.github.js0ny.ilp_coursework.service.DroneInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + @RestController @RequestMapping("/api/v1") public class MapMetaController { + private static final Logger log = LoggerFactory.getLogger(MapMetaController.class); + private final DroneInfoService droneInfoService; public MapMetaController(DroneInfoService droneInfoService) { @@ -22,11 +26,13 @@ public class MapMetaController { @GetMapping("/restrictedAreas") public List getRestrictedAreas() { + log.info("GET /api/v1/restrictedAreas"); return droneInfoService.fetchRestrictedAreas(); } @GetMapping("/servicePoints") public List getServicePoints() { + log.info("GET /api/v1/servicePoints"); return droneInfoService.fetchServicePoints(); } } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/common/DroneEvent.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/common/DroneEvent.java index a056863..79ca2f6 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/common/DroneEvent.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/data/common/DroneEvent.java @@ -1,11 +1,11 @@ package io.github.js0ny.ilp_coursework.data.common; +import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; + import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; - // Corresponding in Go // @@ -18,13 +18,10 @@ import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; * } */ -public record DroneEvent( - String droneId, - double latitude, - double longitude, - String timestamp) { +public record DroneEvent(String droneId, double latitude, double longitude, String timestamp) { + + static final int STEP = 1; // seconds between events - final static int STEP = 1; // seconds between events // Helper method that converts from DeliveryPathResponse to List public static List fromPathResponse(DeliveryPathResponse resp) { @@ -34,11 +31,7 @@ public record DroneEvent( for (var d : p.deliveries()) { for (var coord : d.flightPath()) { String timestamp = java.time.Instant.now().toString(); - events.add(new DroneEvent( - id, - coord.lat(), - coord.lng(), - timestamp)); + events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp)); } } } @@ -47,19 +40,15 @@ public record DroneEvent( // Helper method that converts from DeliveryPathResponse to List // with base timestamp - public static List fromPathResponseWithTimestamp(DeliveryPathResponse resp, - LocalDateTime baseTimestamp) { + public static List fromPathResponseWithTimestamp( + DeliveryPathResponse resp, LocalDateTime baseTimestamp) { List events = new java.util.ArrayList<>(); java.time.LocalDateTime timestamp = baseTimestamp; for (var p : resp.dronePaths()) { String id = String.valueOf(p.droneId()); for (var d : p.deliveries()) { for (var coord : d.flightPath()) { - events.add(new DroneEvent( - id, - coord.lat(), - coord.lng(), - timestamp.toString())); + events.add(new DroneEvent(id, coord.lat(), coord.lng(), timestamp.toString())); timestamp = timestamp.plusSeconds(STEP); // Increment timestamp for each event } } @@ -75,14 +64,11 @@ public record DroneEvent( for (var d : p.deliveries()) { LocalDateTime timestamp = deliveryTimestamps.get(d.deliveryId()); // Fallback to current time if the delivery does not carry a timestamp. - System.out.println("Generated event for drone " + id + " at " + timestamp.toString()); + System.out.println( + "Generated event for drone " + id + " at " + timestamp.toString()); LocalDateTime current = timestamp != null ? timestamp : LocalDateTime.now(); for (var coord : d.flightPath()) { - events.add(new DroneEvent( - id, - coord.lat(), - coord.lng(), - current.toString())); + events.add(new DroneEvent(id, coord.lat(), coord.lng(), current.toString())); current = current.plusSeconds(STEP); } } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java index 35ed1e0..cff9ba3 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/DroneInfoService.java @@ -1,10 +1,6 @@ package io.github.js0ny.ilp_coursework.service; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.github.js0ny.ilp_coursework.data.common.LngLat; -import io.github.js0ny.ilp_coursework.data.common.Region; import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; import io.github.js0ny.ilp_coursework.data.external.ServicePoint; @@ -54,15 +50,13 @@ public class DroneInfoService { /** * Return an array of ids of drones with/without cooling capability * - *

- * Associated service method with {@code /dronesWithCooling/{state}} + *

Associated service method with {@code /dronesWithCooling/{state}} * * @param state determines the capability filtering - * @return if {@code state} is true, return ids of drones with cooling - * capability, else without - * cooling + * @return if {@code state} is true, return ids of drones with cooling capability, else without + * cooling * @see - * io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) + * io.github.js0ny.ilp_coursework.controller.DroneController#getDronesWithCoolingCapability(boolean) */ public List dronesWithCooling(boolean state) { // URI droneUrl = URI.create(baseUrl).resolve(dronesEndpoint); @@ -82,16 +76,13 @@ public class DroneInfoService { /** * Return a {@link Drone}-style json data structure with the given {@code id} * - *

- * Associated service method with {@code /droneDetails/{id}} + *

Associated service method with {@code /droneDetails/{id}} * * @param id The id of the drone * @return drone json body of given id - * @throws NullPointerException when cannot fetch available drones from - * remote - * @throws IllegalArgumentException when drone with given {@code id} cannot be - * found this should - * lead to a 404 + * @throws NullPointerException when cannot fetch available drones from remote + * @throws IllegalArgumentException when drone with given {@code id} cannot be found this should + * lead to a 404 * @see io.github.js0ny.ilp_coursework.controller.DroneController#getDroneDetail(String) */ public Drone droneDetail(String id) { @@ -108,12 +99,10 @@ public class DroneInfoService { } /** - * Return an array of ids of drones that match all the requirements in the - * medical dispatch + * Return an array of ids of drones that match all the requirements in the medical dispatch * records * - *

- * Associated service method with + *

Associated service method with * * @param rec array of medical dispatch records * @return List of drone ids that match all the requirements @@ -135,9 +124,10 @@ public class DroneInfoService { return drones.stream() .filter(d -> d != null && d.capability() != null) .filter( - d -> Arrays.stream(rec) - .filter(r -> r != null && r.requirements() != null) - .allMatch(r -> droneMatchesRequirement(d, r))) + d -> + Arrays.stream(rec) + .filter(r -> r != null && r.requirements() != null) + .allMatch(r -> droneMatchesRequirement(d, r))) .map(Drone::id) .collect(Collectors.toList()); } @@ -145,13 +135,11 @@ public class DroneInfoService { /** * Helper to check if a drone meets the requirement of a medical dispatch. * - * @param drone the drone to be checked + * @param drone the drone to be checked * @param record the medical dispatch record containing the requirement * @return true if the drone meets the requirement, false otherwise - * @throws IllegalArgumentException when record requirements or drone capability - * is invalid - * (capacity and id cannot be null in - * {@code MedDispathRecDto}) + * @throws IllegalArgumentException when record requirements or drone capability is invalid + * (capacity and id cannot be null in {@code MedDispathRecDto}) */ public boolean droneMatchesRequirement(Drone drone, MedDispatchRecRequest record) { var requirements = record.requirements(); @@ -191,13 +179,13 @@ public class DroneInfoService { * Helper to check if a drone is available at the required date and time * * @param droneId the id of the drone to be checked - * @param record the medical dispatch record containing the required date and - * time + * @param record the medical dispatch record containing the required date and time * @return true if the drone is available, false otherwise */ private boolean checkAvailability(String droneId, MedDispatchRecRequest record) { URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); - ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class); + ServicePointDrones[] servicePoints = + restTemplate.getForObject(droneUrl, ServicePointDrones[].class); LocalDate requiredDate = record.date(); DayOfWeek requiredDay = requiredDate.getDayOfWeek(); @@ -216,7 +204,8 @@ public class DroneInfoService { private LngLat queryServicePointLocationByDroneId(String droneId) { URI droneUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); - ServicePointDrones[] servicePoints = restTemplate.getForObject(droneUrl, ServicePointDrones[].class); + ServicePointDrones[] servicePoints = + restTemplate.getForObject(droneUrl, ServicePointDrones[].class); assert servicePoints != null; for (var sp : servicePoints) { @@ -233,7 +222,8 @@ public class DroneInfoService { private LngLat queryServicePointLocation(int id) { URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); - ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class); + ServicePoint[] servicePoints = + restTemplate.getForObject(servicePointUrl, ServicePoint[].class); assert servicePoints != null; for (var sp : servicePoints) { @@ -256,22 +246,24 @@ public class DroneInfoService { public List fetchRestrictedAreas() { URI restrictedUrl = URI.create(baseUrl).resolve(restrictedAreasEndpoint); - RestrictedArea[] restrictedAreas = restTemplate.getForObject(restrictedUrl, RestrictedArea[].class); + RestrictedArea[] restrictedAreas = + restTemplate.getForObject(restrictedUrl, RestrictedArea[].class); assert restrictedAreas != null; return Arrays.asList(restrictedAreas); } public List fetchServicePoints() { URI servicePointUrl = URI.create(baseUrl).resolve(servicePointsEndpoint); - ServicePoint[] servicePoints = restTemplate.getForObject(servicePointUrl, ServicePoint[].class); + ServicePoint[] servicePoints = + restTemplate.getForObject(servicePointUrl, ServicePoint[].class); assert servicePoints != null; return Arrays.asList(servicePoints); } public List fetchDronesForServicePoints() { URI servicePointDronesUrl = URI.create(baseUrl).resolve(dronesForServicePointsEndpoint); - ServicePointDrones[] servicePointDrones = restTemplate.getForObject(servicePointDronesUrl, - ServicePointDrones[].class); + ServicePointDrones[] servicePointDrones = + restTemplate.getForObject(servicePointDronesUrl, ServicePointDrones[].class); assert servicePointDrones != null; return Arrays.asList(servicePointDrones); } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java index ab6ef15..b9d1bbc 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/PathFinderService.java @@ -23,6 +23,7 @@ import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse.DronePa import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -31,7 +32,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import java.time.LocalDateTime; /** * Class that handles calculations about deliverypath @@ -44,10 +44,8 @@ import java.time.LocalDateTime; public class PathFinderService { /** - * Hard stop on how many pathfinding iterations we attempt for a single segment - * before bailing, - * useful for preventing infinite loops caused by precision quirks or unexpected - * map data. + * Hard stop on how many pathfinding iterations we attempt for a single segment before bailing, + * useful for preventing infinite loops caused by precision quirks or unexpected map data. * * @see #computePath(LngLat, LngLat) */ @@ -67,14 +65,11 @@ public class PathFinderService { private TelemetryService telemetryService; /** - * Constructor for PathFinderService. The dependencies are injected by Spring - * and the - * constructor pre-computes reference maps used throughout the request - * lifecycle. + * Constructor for PathFinderService. The dependencies are injected by Spring and the + * constructor pre-computes reference maps used throughout the request lifecycle. * * @param gpsCalculationService Service handling geometric operations. - * @param droneInfoService Service that exposes drone metadata and - * capability information. + * @param droneInfoService Service that exposes drone metadata and capability information. */ public PathFinderService( GpsCalculationService gpsCalculationService, DroneInfoService droneInfoService) { @@ -87,7 +82,8 @@ public class PathFinderService { this.drones = droneInfoService.fetchAllDrones(); List servicePoints = droneInfoService.fetchServicePoints(); - List servicePointAssignments = droneInfoService.fetchDronesForServicePoints(); + List servicePointAssignments = + droneInfoService.fetchDronesForServicePoints(); List restrictedAreas = droneInfoService.fetchRestrictedAreas(); this.droneById = this.drones.stream().collect(Collectors.toMap(Drone::id, drone -> drone)); @@ -105,17 +101,17 @@ public class PathFinderService { } } - this.servicePointLocations = servicePoints.stream() - .collect( - Collectors.toMap( - ServicePoint::id, sp -> new LngLat(sp.location()))); + this.servicePointLocations = + servicePoints.stream() + .collect( + Collectors.toMap( + ServicePoint::id, sp -> new LngLat(sp.location()))); this.restrictedRegions = restrictedAreas.stream().map(RestrictedArea::toRegion).toList(); } /** - * Produce a delivery plan for the provided dispatch records. Deliveries are - * grouped per + * Produce a delivery plan for the provided dispatch records. Deliveries are grouped per * compatible drone and per trip to satisfy each drone move limit. * * @param records Dispatch records to be fulfilled. @@ -161,14 +157,17 @@ public class PathFinderService { continue; } - List sortedDeliveries = entry.getValue().stream() - .sorted( - Comparator.comparingDouble( - rec -> gpsCalculationService.calculateDistance( - servicePointLocation, rec.delivery()))) - .toList(); + List sortedDeliveries = + entry.getValue().stream() + .sorted( + Comparator.comparingDouble( + rec -> + gpsCalculationService.calculateDistance( + servicePointLocation, rec.delivery()))) + .toList(); - List> trips = splitTrips(sortedDeliveries, drone, servicePointLocation); + List> trips = + splitTrips(sortedDeliveries, drone, servicePointLocation); for (List trip : trips) { TripResult result = buildTrip(drone, servicePointLocation, trip); @@ -195,9 +194,9 @@ public class PathFinderService { * GeoJSON FeatureCollection suitable for mapping visualization. * * @param records Dispatch records to be fulfilled. - * + * * @return GeoJSON payload representing every delivery flight path. - * + * * @throws IllegalStateException When the payload cannot be serialized. */ public String calculateDeliveryPathAsGeoJson(MedDispatchRecRequest[] records) { @@ -246,10 +245,8 @@ public class PathFinderService { } /** - * Group dispatch records by their assigned drone, ensuring every record is - * routed through - * {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding - * invalid entries. + * Group dispatch records by their assigned drone, ensuring every record is routed through + * {@link #findBestDrone(MedDispatchRecRequest)} exactly once and discarding invalid entries. * * @param records Dispatch records to be grouped. * @return Map keyed by drone ID with the deliveries it should service. @@ -268,8 +265,7 @@ public class PathFinderService { } /** - * Choose the best drone for the provided record. Currently that equates to - * picking the closest + * Choose the best drone for the provided record. Currently that equates to picking the closest * compatible drone to the delivery location. * * @param record Dispatch record that needs fulfillment. @@ -294,8 +290,9 @@ public class PathFinderService { continue; } - double distance = gpsCalculationService.calculateDistance( - servicePointLocation, record.delivery()); + double distance = + gpsCalculationService.calculateDistance( + servicePointLocation, record.delivery()); if (distance < bestScore) { bestScore = distance; @@ -309,16 +306,14 @@ public class PathFinderService { } /** - * Break a sequence of deliveries into several trips that each respect the drone - * move limit. The + * Break a sequence of deliveries into several trips that each respect the drone move limit. The * deliveries should already be ordered by proximity for sensible grouping. * - * @param deliveries Deliveries assigned to a drone. - * @param drone Drone that will service the deliveries. + * @param deliveries Deliveries assigned to a drone. + * @param drone Drone that will service the deliveries. * @param servicePoint Starting and ending point of every trip. * @return Partitioned trips with at least one delivery each. - * @throws IllegalStateException If a single delivery exceeds the drone's move - * limit. + * @throws IllegalStateException If a single delivery exceeds the drone's move limit. */ private List> splitTrips( List deliveries, Drone drone, LngLat servicePoint) { @@ -354,15 +349,13 @@ public class PathFinderService { } /** - * Build a single trip for the provided drone, including the entire flight path - * to every - * delivery and back home. The resulting structure contains the - * {@link DronePath} representation + * Build a single trip for the provided drone, including the entire flight path to every + * delivery and back home. The resulting structure contains the {@link DronePath} representation * as well as cost and moves consumed. * - * @param drone Drone executing the trip. + * @param drone Drone executing the trip. * @param servicePoint Starting/ending location of the trip. - * @param deliveries Deliveries to include in the trip in execution order. + * @param deliveries Deliveries to include in the trip in execution order. * @return Trip information or {@code null} if no deliveries are provided. * @see DeliveryPathResponse.DronePath */ @@ -402,9 +395,10 @@ public class PathFinderService { flightPlans.add(new Delivery(delivery.id(), flightPath)); } - float cost = drone.capability().costInitial() - + drone.capability().costFinal() - + (float) (drone.capability().costPerMove() * moves); + float cost = + drone.capability().costInitial() + + drone.capability().costFinal() + + (float) (drone.capability().costPerMove() * moves); DronePath path = new DronePath(drone.parseId(), flightPlans); @@ -412,12 +406,11 @@ public class PathFinderService { } /** - * Estimate the number of moves a prospective trip would need by replaying the - * path calculation + * Estimate the number of moves a prospective trip would need by replaying the path calculation * without mutating any persistent state. * * @param servicePoint Trip origin. - * @param deliveries Deliveries that would compose the trip. + * @param deliveries Deliveries that would compose the trip. * @return Total moves required to fly the proposed itinerary. */ private int estimateTripMoves(LngLat servicePoint, List deliveries) { @@ -436,11 +429,10 @@ public class PathFinderService { } /** - * Build a path between {@code start} and {@code target} by repeatedly moving in - * snapped + * Build a path between {@code start} and {@code target} by repeatedly moving in snapped * increments while avoiding restricted zones. * - * @param start Start coordinate. + * @param start Start coordinate. * @param target Destination coordinate. * @return Sequence of visited coordinates and move count. * @see #nextPosition(LngLat, LngLat) @@ -470,20 +462,18 @@ public class PathFinderService { } /** - * Determine the next position on the path from {@code current} toward - * {@code target}, - * preferring the snapped angle closest to the desired heading that does not - * infiltrate a + * Determine the next position on the path from {@code current} toward {@code target}, + * preferring the snapped angle closest to the desired heading that does not infiltrate a * restricted region. * * @param current Current coordinate. - * @param target Destination coordinate. - * @return Next admissible coordinate or the original point if none can be - * found. + * @param target Destination coordinate. + * @return Next admissible coordinate or the original point if none can be found. */ private LngLat nextPosition(LngLat current, LngLat target) { - double desiredAngle = Math.toDegrees( - Math.atan2(target.lat() - current.lat(), target.lng() - current.lng())); + double desiredAngle = + Math.toDegrees( + Math.atan2(target.lat() - current.lat(), target.lng() - current.lng())); List candidateAngles = buildAngleCandidates(desiredAngle); for (Angle angle : candidateAngles) { LngLat next = gpsCalculationService.nextPosition(current, angle); @@ -495,10 +485,8 @@ public class PathFinderService { } /** - * Build a sequence of candidate angles centered on the desired heading, - * expanding symmetrically - * clockwise and counter-clockwise to explore alternative headings if the - * primary path is + * Build a sequence of candidate angles centered on the desired heading, expanding symmetrically + * clockwise and counter-clockwise to explore alternative headings if the primary path is * blocked. * * @param desiredAngle Bearing in degrees between current and target positions. @@ -533,17 +521,15 @@ public class PathFinderService { } /** - * Representation of a computed path segment wrapping the visited positions and - * the number of + * Representation of a computed path segment wrapping the visited positions and the number of * moves taken to traverse them. * * @param positions Ordered coordinates that describe the path. - * @param moves Number of moves consumed by the path. + * @param moves Number of moves consumed by the path. */ private record PathSegment(List positions, int moves) { /** - * Append the positions from this segment to {@code target}, skipping the first - * coordinate + * Append the positions from this segment to {@code target}, skipping the first coordinate * as it is already represented by the last coordinate in the consumer path. * * @param target Mutable list to append to. @@ -556,10 +542,8 @@ public class PathFinderService { } /** - * Bundle containing the calculated {@link DronePath}, total moves and financial - * cost for a + * Bundle containing the calculated {@link DronePath}, total moves and financial cost for a * single trip. */ - private record TripResult(DronePath path, int moves, float cost) { - } + private record TripResult(DronePath path, int moves, float cost) {} } diff --git a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/TelemetryService.java b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/TelemetryService.java index 33cfc24..00f58a4 100644 --- a/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/TelemetryService.java +++ b/ilp-rest-service/src/main/java/io/github/js0ny/ilp_coursework/service/TelemetryService.java @@ -1,20 +1,24 @@ package io.github.js0ny.ilp_coursework.service; -import java.net.http.HttpClient; -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.concurrent.CompletableFuture; -import java.util.Map; - -import org.springframework.stereotype.Service; - import com.fasterxml.jackson.databind.ObjectMapper; import io.github.js0ny.ilp_coursework.data.common.DroneEvent; import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + @Service public class TelemetryService { + private static final Logger log = LoggerFactory.getLogger(TelemetryService.class); + private final HttpClient client; private final ObjectMapper mapper; @@ -24,7 +28,8 @@ public class TelemetryService { this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); this.mapper = new ObjectMapper(); - this.BLACKBOX_URL = System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000"); + this.BLACKBOX_URL = + System.getenv().getOrDefault("BLACKBOX_ENDPOINT", "http://localhost:3000"); } public void sendEventAsyncByPathResponse(DeliveryPathResponse resp) { @@ -34,15 +39,16 @@ public class TelemetryService { } } - public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, LocalDateTime baseTimestamp) { + public void sendEventAsyncByPathResponse( + DeliveryPathResponse resp, LocalDateTime baseTimestamp) { var events = DroneEvent.fromPathResponseWithTimestamp(resp, baseTimestamp); for (var event : events) { sendEventAsync(event); } } - public void sendEventAsyncByPathResponse(DeliveryPathResponse resp, - Map deliveryTimestamps) { + public void sendEventAsyncByPathResponse( + DeliveryPathResponse resp, Map deliveryTimestamps) { var events = DroneEvent.fromPathResponseWithTimestamps(resp, deliveryTimestamps); for (var event : events) { sendEventAsync(event); @@ -50,22 +56,24 @@ public class TelemetryService { } public void sendEventAsync(DroneEvent event) { - CompletableFuture.runAsync(() -> { - try { - String json = mapper.writeValueAsString(event); - System.out.println("[INFO] Sending telemetry event: " + json); - var request = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(BLACKBOX_URL + "/ingest")) - .header("Content-Type", "application/json") - .POST(java.net.http.HttpRequest.BodyPublishers.ofString(json)) - .build(); - - client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); - } catch (Exception e) { - System.err.println("[ERROR] Failed to send telemetry event: " + e.getMessage()); - } - }); + CompletableFuture.runAsync( + () -> { + try { + String json = mapper.writeValueAsString(event); + log.debug("Sending telemetry event: {}", json); + var request = + java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(BLACKBOX_URL + "/ingest")) + .header("Content-Type", "application/json") + .POST( + java.net.http.HttpRequest.BodyPublishers.ofString( + json)) + .build(); + client.send(request, java.net.http.HttpResponse.BodyHandlers.ofString()); + } catch (Exception e) { + log.error("Failed to send telemetry event: {}", e.getMessage()); + } + }); } - } diff --git a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/DroneControllerTest.java b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/DroneControllerTest.java index a377da7..54a9627 100644 --- a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/DroneControllerTest.java +++ b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/DroneControllerTest.java @@ -15,6 +15,7 @@ import io.github.js0ny.ilp_coursework.data.common.LngLat; import io.github.js0ny.ilp_coursework.data.external.Drone; import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; +import io.github.js0ny.ilp_coursework.data.response.DeliveryPathResponse; import io.github.js0ny.ilp_coursework.service.DroneAttrComparatorService; import io.github.js0ny.ilp_coursework.service.DroneInfoService; import io.github.js0ny.ilp_coursework.service.PathFinderService; @@ -409,4 +410,79 @@ public class DroneControllerTest { .andExpect(content().json(objectMapper.writeValueAsString(expected))); } } + + @Nested + @DisplayName("POST /calcDeliveryPath") + class PostCalcDeliveryPathTests { + + final String API_ENDPOINT = "/api/v1/calcDeliveryPath"; + + @Test + @DisplayName("Example -> 200 OK") + void postCalcDeliveryPath_shouldReturn200AndJson_whenExampleRequest() throws Exception { + var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f); + var delivery = new LngLat(-3.00, 55.121); + var record = + new MedDispatchRecRequest( + 123, + LocalDate.parse("2025-12-22"), + LocalTime.parse("14:30"), + reqs, + delivery); + MedDispatchRecRequest[] requestBody = {record}; + + var flightPath = List.of(new LngLat(-3.0, 55.12), new LngLat(-3.01, 55.13)); + var deliveryPath = + new DeliveryPathResponse.DronePath.Delivery(123, flightPath); + var dronePath = new DeliveryPathResponse.DronePath(1, List.of(deliveryPath)); + DeliveryPathResponse expected = + new DeliveryPathResponse( + 12.5f, 42, new DeliveryPathResponse.DronePath[] {dronePath}); + + when(pathFinderService.calculateDeliveryPath(any(MedDispatchRecRequest[].class))) + .thenReturn(expected); + + mockMvc.perform( + post(API_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + } + + @Nested + @DisplayName("POST /calcDeliveryPathAsGeoJson") + class PostCalcDeliveryPathAsGeoJsonTests { + + final String API_ENDPOINT = "/api/v1/calcDeliveryPathAsGeoJson"; + + @Test + @DisplayName("Example -> 200 OK") + void postCalcDeliveryPathAsGeoJson_shouldReturn200AndGeoJson_whenExampleRequest() + throws Exception { + var reqs = new MedDispatchRecRequest.MedRequirement(0.75f, false, true, 13.5f); + var delivery = new LngLat(-3.00, 55.121); + var record = + new MedDispatchRecRequest( + 123, + LocalDate.parse("2025-12-22"), + LocalTime.parse("14:30"), + reqs, + delivery); + MedDispatchRecRequest[] requestBody = {record}; + String expected = "{\"type\":\"FeatureCollection\",\"features\":[]}"; + + when(pathFinderService.calculateDeliveryPathAsGeoJson( + any(MedDispatchRecRequest[].class))) + .thenReturn(expected); + + mockMvc.perform( + post(API_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestBody))) + .andExpect(status().isOk()) + .andExpect(content().string(expected)); + } + } } diff --git a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/MapMetaControllerTest.java b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/MapMetaControllerTest.java new file mode 100644 index 0000000..b1b1535 --- /dev/null +++ b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/controller/MapMetaControllerTest.java @@ -0,0 +1,81 @@ +package io.github.js0ny.ilp_coursework.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.js0ny.ilp_coursework.data.common.AltitudeRange; +import io.github.js0ny.ilp_coursework.data.common.LngLatAlt; +import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; +import io.github.js0ny.ilp_coursework.data.external.ServicePoint; +import io.github.js0ny.ilp_coursework.service.DroneInfoService; + +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.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 java.util.List; + +@WebMvcTest(MapMetaController.class) +public class MapMetaControllerTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private DroneInfoService droneInfoService; + + @Nested + @DisplayName("GET /restrictedAreas") + class RestrictedAreasTests { + + @Test + @DisplayName("-> 200 OK") + void getRestrictedAreas_shouldReturn200AndJson() throws Exception { + String endpoint = "/api/v1/restrictedAreas"; + RestrictedArea area = + new RestrictedArea( + "Zone A", + 1, + new AltitudeRange(0.0, 120.0), + new LngLatAlt[] { + new LngLatAlt(0.0, 0.0, 0.0), + new LngLatAlt(1.0, 0.0, 0.0), + new LngLatAlt(1.0, 1.0, 0.0), + new LngLatAlt(0.0, 1.0, 0.0) + }); + List expected = List.of(area); + when(droneInfoService.fetchRestrictedAreas()).thenReturn(expected); + + var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON)); + mock.andExpect(status().isOk()); + mock.andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + } + + @Nested + @DisplayName("GET /servicePoints") + class ServicePointsTests { + + @Test + @DisplayName("-> 200 OK") + void getServicePoints_shouldReturn200AndJson() throws Exception { + String endpoint = "/api/v1/servicePoints"; + ServicePoint point = new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 12.0)); + List expected = List.of(point); + when(droneInfoService.fetchServicePoints()).thenReturn(expected); + + var mock = mockMvc.perform(get(endpoint).contentType(MediaType.APPLICATION_JSON)); + mock.andExpect(status().isOk()); + mock.andExpect(content().json(objectMapper.writeValueAsString(expected))); + } + } +} diff --git a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneAttrComparatorServiceTest.java b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneAttrComparatorServiceTest.java new file mode 100644 index 0000000..9077d92 --- /dev/null +++ b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneAttrComparatorServiceTest.java @@ -0,0 +1,107 @@ +package io.github.js0ny.ilp_coursework.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.js0ny.ilp_coursework.data.common.DroneCapability; +import io.github.js0ny.ilp_coursework.data.external.Drone; +import io.github.js0ny.ilp_coursework.data.request.AttrQueryRequest; + +import org.junit.jupiter.api.AfterEach; +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.junit.jupiter.api.extension.ExtendWith; +import org.springframework.http.MediaType; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(SpringExtension.class) +public class DroneAttrComparatorServiceTest { + + private DroneAttrComparatorService service; + private MockRestServiceServer server; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new DroneAttrComparatorService(); + RestTemplate restTemplate = + (RestTemplate) ReflectionTestUtils.getField(service, "restTemplate"); + server = MockRestServiceServer.createServer(restTemplate); + ReflectionTestUtils.setField(service, "baseUrl", "http://localhost/"); + objectMapper = new ObjectMapper(); + } + + @AfterEach + void tearDown() { + server.verify(); + } + + @Nested + @DisplayName("dronesWithAttribute(String, String) tests") + class DronesWithAttributeTests { + + @Test + @DisplayName("Should return matching ids for boolean attribute") + void dronesWithAttribute_shouldReturnMatchingIds() throws Exception { + Drone[] drones = { + new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)), + new Drone("Drone 2", "2", new DroneCapability(false, true, 5, 500, 1, 1, 1)), + new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 800, 1, 1, 1)) + }; + String responseBody = objectMapper.writeValueAsString(drones); + server.expect(requestTo("http://localhost/drones")) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + var result = service.dronesWithAttribute("cooling", "true"); + + assertThat(result).containsExactly("1", "3"); + } + } + + @Nested + @DisplayName("dronesSatisfyingAttributes(AttrQueryRequest[]) tests") + class DronesSatisfyingAttributesTests { + + @Test + @DisplayName("Should return intersection of all rules") + void dronesSatisfyingAttributes_shouldReturnIntersection() throws Exception { + Drone[] drones = { + new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)), + new Drone("Drone 2", "2", new DroneCapability(true, true, 3, 1000, 1, 1, 1)), + new Drone("Drone 3", "3", new DroneCapability(true, false, 12, 1000, 1, 1, 1)) + }; + String responseBody = objectMapper.writeValueAsString(drones); + server.expect(requestTo("http://localhost/drones")) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + server.expect(requestTo("http://localhost/drones")) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + + AttrQueryRequest[] comparators = { + new AttrQueryRequest("capacity", ">", "5"), + new AttrQueryRequest("heating", "=", "true") + }; + + var result = service.dronesSatisfyingAttributes(comparators); + + assertThat(result).containsExactly("1"); + } + + @Test + @DisplayName("Should return empty list when no comparators") + void dronesSatisfyingAttributes_shouldReturnEmpty_whenNoComparators() { + AttrQueryRequest[] comparators = {}; + + var result = service.dronesSatisfyingAttributes(comparators); + + assertThat(result).isEmpty(); + } + } +} diff --git a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneInfoServiceTest.java b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneInfoServiceTest.java index 6595e66..b549f52 100644 --- a/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneInfoServiceTest.java +++ b/ilp-rest-service/src/test/java/io/github/js0ny/ilp_coursework/service/DroneInfoServiceTest.java @@ -4,11 +4,15 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; +import io.github.js0ny.ilp_coursework.data.common.AltitudeRange; import io.github.js0ny.ilp_coursework.data.common.DroneAvailability; import io.github.js0ny.ilp_coursework.data.common.DroneCapability; import io.github.js0ny.ilp_coursework.data.common.LngLat; +import io.github.js0ny.ilp_coursework.data.common.LngLatAlt; import io.github.js0ny.ilp_coursework.data.common.TimeWindow; import io.github.js0ny.ilp_coursework.data.external.Drone; +import io.github.js0ny.ilp_coursework.data.external.RestrictedArea; +import io.github.js0ny.ilp_coursework.data.external.ServicePoint; import io.github.js0ny.ilp_coursework.data.external.ServicePointDrones; import io.github.js0ny.ilp_coursework.data.request.MedDispatchRecRequest; @@ -230,4 +234,145 @@ public class DroneInfoServiceTest { assertThat(resultEmpty).containsExactly("1", "2", "3"); } } + + @Nested + @DisplayName("droneMatchesRequirement(Drone, MedDispatchRecRequest) tests") + class DroneMatchesRequirementTests { + + @Test + @DisplayName("Should throw when requirements are null") + void droneMatchesRequirement_shouldThrow_whenRequirementsNull() { + Drone drone = + new Drone("Drone 1", "1", new DroneCapability(true, true, 10, 1000, 1, 1, 1)); + MedDispatchRecRequest record = + new MedDispatchRecRequest( + 1, LocalDate.now(), LocalTime.of(9, 0), null, new LngLat(0, 0)); + + assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("requirements cannot be null"); + } + + @Test + @DisplayName("Should throw when drone capability is null") + void droneMatchesRequirement_shouldThrow_whenCapabilityNull() { + Drone drone = new Drone("Drone 1", "1", null); + MedDispatchRecRequest record = + new MedDispatchRecRequest( + 1, + LocalDate.now(), + LocalTime.of(9, 0), + new MedDispatchRecRequest.MedRequirement(1, false, false, 10), + new LngLat(0, 0)); + + assertThatThrownBy(() -> droneInfoService.droneMatchesRequirement(drone, record)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("drone capability cannot be null"); + } + } + + @Nested + @DisplayName("fetchAllDrones() tests") + class FetchAllDronesTests { + + @Test + @DisplayName("Should return list when API returns drones") + void fetchAllDrones_shouldReturnList_whenApiReturnsDrones() { + Drone[] drones = getMockDrones(); + when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class)) + .thenReturn(drones); + + List result = droneInfoService.fetchAllDrones(); + + assertThat(result).hasSize(3); + assertThat(result.get(0).id()).isEqualTo("1"); + } + + @Test + @DisplayName("Should return empty list when API returns null") + void fetchAllDrones_shouldReturnEmptyList_whenApiReturnsNull() { + when(restTemplate.getForObject(URI.create(baseUrl + "drones"), Drone[].class)) + .thenReturn(null); + + List result = droneInfoService.fetchAllDrones(); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("fetchRestrictedAreas() tests") + class FetchRestrictedAreasTests { + + @Test + @DisplayName("Should return restricted areas") + void fetchRestrictedAreas_shouldReturnList() { + RestrictedArea[] areas = { + new RestrictedArea( + "Zone A", + 1, + new AltitudeRange(0, 100), + new LngLatAlt[] { + new LngLatAlt(0, 0, 0), + new LngLatAlt(1, 0, 0), + new LngLatAlt(1, 1, 0) + }) + }; + when(restTemplate.getForObject( + URI.create(baseUrl + "restricted-areas"), RestrictedArea[].class)) + .thenReturn(areas); + + List result = droneInfoService.fetchRestrictedAreas(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("Zone A"); + } + } + + @Nested + @DisplayName("fetchServicePoints() tests") + class FetchServicePointsTests { + + @Test + @DisplayName("Should return service points") + void fetchServicePoints_shouldReturnList() { + ServicePoint[] points = { + new ServicePoint("Point A", 1, new LngLatAlt(0.1, 0.2, 3.0)) + }; + when(restTemplate.getForObject( + URI.create(baseUrl + "service-points"), ServicePoint[].class)) + .thenReturn(points); + + List result = droneInfoService.fetchServicePoints(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("Point A"); + } + } + + @Nested + @DisplayName("fetchDronesForServicePoints() tests") + class FetchDronesForServicePointsTests { + + @Test + @DisplayName("Should return service point drones") + void fetchDronesForServicePoints_shouldReturnList() { + TimeWindow[] timeWindows = { + new TimeWindow(DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(17, 0)) + }; + DroneAvailability drone1Avail = new DroneAvailability("1", timeWindows); + ServicePointDrones spd = + new ServicePointDrones(1, new DroneAvailability[] {drone1Avail}); + ServicePointDrones[] servicePointDrones = {spd}; + when(restTemplate.getForObject( + URI.create(baseUrl + "drones-for-service-points"), + ServicePointDrones[].class)) + .thenReturn(servicePointDrones); + + List result = droneInfoService.fetchDronesForServicePoints(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).servicePointId()).isEqualTo(1); + } + } }