Compare commits

...

13 commits

Author SHA1 Message Date
f013955bc2 ci(procedure): Detailed CI pipeline
Some checks are pending
Polyglot CI / tests (push) Waiting to run
2026-01-22 11:02:06 +00:00
fb48c58a30 fix(coverage): detailed coverage test filtering 2026-01-22 10:53:09 +00:00
326c31c149 feat(logging): Add logging for REST Controller 2026-01-22 08:56:13 +00:00
49646d97ed ci(e2e): Add deps 2026-01-22 08:48:09 +00:00
ba670223de ci(e2e): Add e2e ci 2026-01-22 08:39:11 +00:00
1a278fdcea docs(README): Add Workflow badge and repo description 2026-01-22 08:19:25 +00:00
aabecad5af ci(deps): Add fd 2026-01-22 08:11:55 +00:00
dc9c372e07 ci(nix): Apply ci devshell 2026-01-22 08:09:22 +00:00
c782934ea7 ci(nix): Add ci-specific devShell 2026-01-22 08:08:32 +00:00
a4ccc16f8f ci(typos): fix typo 2026-01-22 08:03:08 +00:00
b9af4ef5c5 ci(test): Test CI status 2026-01-22 08:02:06 +00:00
ada2a0ceb3 ci(test): Test of CI 2026-01-22 08:01:15 +00:00
4e623fada6 ci(automation): pre-ci setup 2026-01-22 07:50:30 +00:00
21 changed files with 882 additions and 209 deletions

54
.bin/e2e.sh Normal file
View file

@ -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

32
.github/workflows/ci.yml vendored Normal file
View file

@ -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

6
.gitignore vendored
View file

@ -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

29
.justfile Normal file
View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)"

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -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<String> 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<Drone> 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<String> getIdByAttrMap(
@PathVariable String attrName, @PathVariable String attrVal) {
log.info("GET /api/v1/queryAsPath/{}/{}", attrName, attrVal);
return droneAttrComparatorService.dronesWithAttribute(attrName, attrVal);
}
@PostMapping("/query")
public List<String> 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<String> 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);
}
}

View file

@ -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<RestrictedArea> getRestrictedAreas() {
log.info("GET /api/v1/restrictedAreas");
return droneInfoService.fetchRestrictedAreas();
}
@GetMapping("/servicePoints")
public List<ServicePoint> getServicePoints() {
log.info("GET /api/v1/servicePoints");
return droneInfoService.fetchServicePoints();
}
}

View file

@ -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<DroneEvent>
public static List<DroneEvent> 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<DroneEvent>
// with base timestamp
public static List<DroneEvent> fromPathResponseWithTimestamp(DeliveryPathResponse resp,
LocalDateTime baseTimestamp) {
public static List<DroneEvent> fromPathResponseWithTimestamp(
DeliveryPathResponse resp, LocalDateTime baseTimestamp) {
List<DroneEvent> 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);
}
}

View file

@ -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
*
* <p>
* Associated service method with {@code /dronesWithCooling/{state}}
* <p>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<String> 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}
*
* <p>
* Associated service method with {@code /droneDetails/{id}}
* <p>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
*
* <p>
* Associated service method with
* <p>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<RestrictedArea> 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<ServicePoint> 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<ServicePointDrones> 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);
}

View file

@ -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<ServicePoint> servicePoints = droneInfoService.fetchServicePoints();
List<ServicePointDrones> servicePointAssignments = droneInfoService.fetchDronesForServicePoints();
List<ServicePointDrones> servicePointAssignments =
droneInfoService.fetchDronesForServicePoints();
List<RestrictedArea> 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<MedDispatchRecRequest> sortedDeliveries = entry.getValue().stream()
.sorted(
Comparator.comparingDouble(
rec -> gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery())))
.toList();
List<MedDispatchRecRequest> sortedDeliveries =
entry.getValue().stream()
.sorted(
Comparator.comparingDouble(
rec ->
gpsCalculationService.calculateDistance(
servicePointLocation, rec.delivery())))
.toList();
List<List<MedDispatchRecRequest>> trips = splitTrips(sortedDeliveries, drone, servicePointLocation);
List<List<MedDispatchRecRequest>> trips =
splitTrips(sortedDeliveries, drone, servicePointLocation);
for (List<MedDispatchRecRequest> 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<List<MedDispatchRecRequest>> splitTrips(
List<MedDispatchRecRequest> 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<MedDispatchRecRequest> 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<Angle> 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<LngLat> 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) {}
}

View file

@ -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<Integer, LocalDateTime> deliveryTimestamps) {
public void sendEventAsyncByPathResponse(
DeliveryPathResponse resp, Map<Integer, LocalDateTime> 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());
}
});
}
}

View file

@ -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));
}
}
}

View file

@ -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<RestrictedArea> 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<ServicePoint> 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)));
}
}
}

View file

@ -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();
}
}
}

View file

@ -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<Drone> 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<Drone> 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<RestrictedArea> 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<ServicePoint> 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<ServicePointDrones> result = droneInfoService.fetchDronesForServicePoints();
assertThat(result).hasSize(1);
assertThat(result.get(0).servicePointId()).isEqualTo(1);
}
}
}