add integrations tests + http client (2 practical) + agents.md

This commit is contained in:
2025-12-16 21:23:27 +02:00
parent 5590e0a134
commit 7ed2b7cb85
18 changed files with 267 additions and 64 deletions

18
AGENTS.md Normal file
View File

@@ -0,0 +1,18 @@
# AGENTS.md
## Build/Test Commands
- Build: `./gradlew build`
- Test all: `./gradlew test`
- Run single test: `./gradlew test --tests "FreightIntegrationTest.getByIdReturnsEntity"`
- Run app: `./gradlew bootRun`
## Code Style Guidelines
- Package structure: `ua.com.dxrkness.{controller,service,repository,model}`
- Use Spring Boot annotations (@RestController, @Service, etc.)
- Records for models with Jackson @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
- Constructor injection for dependencies
- JSpecify annotations (@NullMarked, @Nullable) for null safety
- Integration tests with @SpringBootTest and parameterized tests
- REST controllers support both JSON and XML media types
- Use ResponseEntity for HTTP responses with proper status codes
- OpenAPI documentation with Swagger annotations on controllers

View File

@@ -18,6 +18,9 @@ dependencies {
implementation(libs.spring.boot.starter.hateoas)
developmentOnly(libs.spring.boot.devtools)
// http client
implementation(libs.apache.http.client)
// openapi docs
implementation(libs.springdoc.openapi.starter.webmvc.ui)

View File

@@ -3,6 +3,7 @@ junit = "6.0.1"
spring-boot-plugin = "4.0.0"
jspecify = "1.0.0"
springdoc = "3.0.0"
apache-http-client = "4.5.14"
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot-plugin" }
@@ -14,6 +15,9 @@ spring-boot-starter-web-test = { group = "org.springframework.boot", name = "spr
spring-boot-starter-hateoas = { group = "org.springframework.boot", name = "spring-boot-starter-hateoas" }
spring-boot-devtools = { group = "org.springframework.boot", name = "spring-boot-devtools" }
# http client
apache-http-client = { group = "org.apache.httpcomponents", name = "httpclient", version.ref ="apache-http-client" }
# openapi
springdoc-openapi-starter-webmvc-ui = { group = "org.springdoc", name = "springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" }

View File

@@ -1,10 +1,167 @@
package ua.com.dxrkness.client;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.apache.http.client.methods.*;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.xml.XmlMapper;
import ua.com.dxrkness.model.Freight;
import ua.com.dxrkness.model.Route;
import ua.com.dxrkness.model.Vehicle;
import java.util.List;
public class ApplicationClient {
private static final String BASE_URL = "http://localhost:8080";
private static final ObjectMapper jsonMapper = new ObjectMapper();
private static final XmlMapper xmlMapper = new XmlMapper();
static void main() {
try (CloseableHttpClient client = HttpClients.createDefault()) {
testVehicles(client);
testFreights(client);
testRoutes(client);
} catch (Exception e) {
System.err.println("error: " + e.getMessage());
e.printStackTrace();
}
}
private static void testVehicles(CloseableHttpClient client) throws Exception {
System.out.println("--- VEHICLES ---");
execute(client, new HttpGet(BASE_URL + "/vehicles"), "GET all vehicles");
Vehicle newVehicle = new Vehicle(0, "Mercedes", "Actros", "AA1234BB", 2023, 18000, Vehicle.Status.AVAILABLE);
String vehicleXml = xmlMapper.writeValueAsString(newVehicle);
HttpPost post = new HttpPost(BASE_URL + "/vehicles");
post.setEntity(new StringEntity(vehicleXml, "UTF-8"));
post.setHeader("Content-Type", "application/xml");
post.setHeader("Accept", "application/xml");
String createdVehicle = execute(client, post, "POST new vehicle");
var get = new HttpGet(BASE_URL + "/vehicles/0");
get.setHeader("Content-Type", "application/xml");
execute(client, get, "GET vehicle by ID=0");
execute(client, new HttpGet(BASE_URL + "/vehicles/999"), "GET vehicle by ID=999 (not found)");
Vehicle updateVehicle = new Vehicle(1, "Volvo", "FH16", "BB5678CC", 2024, 20000, Vehicle.Status.MAINTENANCE);
String updateJson = jsonMapper.writeValueAsString(updateVehicle);
HttpPut put = new HttpPut(BASE_URL + "/vehicles/0");
put.setEntity(new StringEntity(updateJson, "UTF-8"));
put.setHeader("Content-Type", "application/json");
execute(client, put, "PUT update vehicle ID=0");
Vehicle patchVehicle = new Vehicle(1, "Volvo", "FH16", "BB5678CC", 2024, 20000, Vehicle.Status.IN_TRANSIT);
String patchJson = jsonMapper.writeValueAsString(patchVehicle);
HttpPatch patch = new HttpPatch(BASE_URL + "/vehicles/0");
patch.setEntity(new StringEntity(patchJson, "UTF-8"));
patch.setHeader("Content-Type", "application/json");
execute(client, patch, "PATCH update vehicle ID=0");
execute(client, new HttpDelete(BASE_URL + "/vehicles/999"), "DELETE vehicle ID=999 (not found)");
System.out.println();
}
private static void testFreights(CloseableHttpClient client) throws Exception {
System.out.println("--- FREIGHTS ---");
execute(client, new HttpGet(BASE_URL + "/freights"), "GET all freights");
Freight.Dimensions dims = new Freight.Dimensions(120, 100, 200);
Freight newFreight = new Freight(0, "Electronics", "Laptops and monitors", 500, dims, Freight.Status.PENDING);
String freightJson = jsonMapper.writeValueAsString(newFreight);
HttpPost post = new HttpPost(BASE_URL + "/freights");
post.setEntity(new StringEntity(freightJson, "UTF-8"));
post.setHeader("Content-Type", "application/json");
execute(client, post, "POST new freight");
execute(client, new HttpGet(BASE_URL + "/freights/0"), "GET freight by ID=1");
execute(client, new HttpGet(BASE_URL + "/freights/999"), "GET freight by ID=999 (not found)");
Freight updateFreight = new Freight(1, "Furniture", "Office desks", 800, dims, Freight.Status.IN_TRANSIT);
String updateJson = jsonMapper.writeValueAsString(updateFreight);
HttpPut put = new HttpPut(BASE_URL + "/freights/0");
put.setEntity(new StringEntity(updateJson, "UTF-8"));
put.setHeader("Content-Type", "application/json");
execute(client, put, "PUT update freight ID=1");
Freight patchFreight = new Freight(1, "Furniture", "Office desks", 800, dims, Freight.Status.DELIVERED);
String patchJson = jsonMapper.writeValueAsString(patchFreight);
HttpPatch patch = new HttpPatch(BASE_URL + "/freights/0");
patch.setEntity(new StringEntity(patchJson, "UTF-8"));
patch.setHeader("Content-Type", "application/json");
execute(client, patch, "PATCH update freight ID=1");
execute(client, new HttpDelete(BASE_URL + "/freights/999"), "DELETE freight ID=999 (not found)");
System.out.println();
}
private static void testRoutes(CloseableHttpClient client) throws Exception {
System.out.println("--- ROUTES ---");
execute(client, new HttpGet(BASE_URL + "/routes"), "GET all routes");
execute(client, new HttpGet(BASE_URL + "/routes?vehicleId=0"), "GET routes by vehicleId=1");
execute(client, new HttpGet(BASE_URL + "/routes?freightId=0"), "GET routes by freightId=1");
Route newRoute = new Route(0, 0, List.of(0L, 1L), "Kyiv", "Lviv", 540.0, 8.5, Route.Status.PLANNED);
String routeJson = jsonMapper.writeValueAsString(newRoute);
HttpPost post = new HttpPost(BASE_URL + "/routes");
post.setEntity(new StringEntity(routeJson, "UTF-8"));
post.setHeader("Content-Type", "application/json");
execute(client, post, "POST new route");
execute(client, new HttpGet(BASE_URL + "/routes/0"), "GET route by ID=0");
execute(client, new HttpGet(BASE_URL + "/routes/999"), "GET route by ID=999 (not found)");
Route updateRoute = new Route(1, 1, List.of(1L), "Kyiv", "Odesa", 480.0, 7.0, Route.Status.IN_PROGRESS);
String updateJson = jsonMapper.writeValueAsString(updateRoute);
HttpPut put = new HttpPut(BASE_URL + "/routes/0");
put.setEntity(new StringEntity(updateJson, "UTF-8"));
put.setHeader("Content-Type", "application/json");
execute(client, put, "PUT update route ID=0");
Route patchRoute = new Route(1, 1, List.of(1L), "Kyiv", "Odesa", 480.0, 7.0, Route.Status.COMPLETED);
String patchJson = jsonMapper.writeValueAsString(patchRoute);
HttpPatch patch = new HttpPatch(BASE_URL + "/routes/0");
patch.setEntity(new StringEntity(patchJson, "UTF-8"));
patch.setHeader("Content-Type", "application/json");
execute(client, patch, "PATCH update route ID=0");
execute(client, new HttpDelete(BASE_URL + "/routes/999"), "DELETE route ID=999 (not found)");
System.out.println();
}
private static String execute(CloseableHttpClient client, HttpUriRequest request, String description) {
try {
System.out.println("[" + request.getMethod() + "] " + description);
CloseableHttpResponse response = client.execute(request);
int statusCode = response.getStatusLine().getStatusCode();
String body = response.getEntity() != null ? EntityUtils.toString(response.getEntity()) : "";
System.out.println("Status: " + statusCode);
if (!body.isEmpty()) {
System.out.println("Response: " + body);
}
if (statusCode >= 400) {
System.err.println("ERROR: Request failed with status " + statusCode);
}
System.out.println();
return body;
} catch (Exception e) {
System.err.println("EXCEPTION: " + e.getMessage());
e.printStackTrace();
System.out.println();
return null;
}
}
}

View File

@@ -5,16 +5,17 @@ import tools.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record Freight(long id,
String brand,
String model,
String licensePlate,
int year,
int capacityKg,
String name,
String description,
int weightKg,
Dimensions dimensions,
Status status) implements Identifiable {
public record Dimensions(int widthCm,
int heightCm,
int lengthCm) {
}
public enum Status {
PLANNED,
IN_PROGRESS,
COMPLETED,
CANCELLED
PENDING, IN_TRANSIT, DELIVERED
}
}

View File

@@ -5,17 +5,15 @@ import tools.jackson.databind.annotation.JsonNaming;
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record Vehicle(long id,
String name,
String description,
int weightKg,
Dimensions dimensions,
String brand,
String model,
String licensePlate,
int year,
int capacityKg,
Status status) implements Identifiable {
public record Dimensions(int widthCm,
int heightCm,
int lengthCm) {
}
public enum Status {
PENDING, IN_TRANSIT, DELIVERED
AVAILABLE,
IN_TRANSIT,
MAINTENANCE
}
}

View File

@@ -12,9 +12,9 @@ public final class RouteRepository extends CrudRepository<Route> {
public @Nullable Route getByFreightId(long freightId) {
Route found = null;
out: for (var route : STORAGE) {
var freights = route.freights();
var freights = route.freightId();
for (var freight : freights) {
if (freight.id() == freightId) {
if (freight == freightId) {
found = route;
break out;
}
@@ -26,7 +26,7 @@ public final class RouteRepository extends CrudRepository<Route> {
public List<Route> getByVehicleId(long vehicleId) {
List<Route> found = new ArrayList<>();
for (var route : STORAGE) {
if (route.vehicle().id() == vehicleId) {
if (route.vehicleId() == vehicleId) {
found.add(route);
break;
}

View File

@@ -21,7 +21,7 @@ public final class FreightService {
public Freight add(Freight freight) {
int id = repo.add(freight);
return new Freight(id);
return new Freight(id, freight.name(), freight.description(), freight.weightKg(), freight.dimensions(), freight.status());
}
public @Nullable Freight getById(long id) {
@@ -29,7 +29,7 @@ public final class FreightService {
}
public Freight update(long id, Freight newFreight) {
newFreight = new Freight(id);
newFreight = new Freight(id, newFreight.name(), newFreight.description(), newFreight.weightKg(), newFreight.dimensions(), newFreight.status());
return repo.update(id, newFreight);
}

View File

@@ -26,22 +26,18 @@ public class PopulateService {
}
public void populate(int vals){
var allFreights = new ArrayList<Freight>(vals);
for (int i = 0; i < vals; i++) {
var f = new Freight(i);
allFreights.add(f);
var f = new Freight(i, "", "", 0, null, null);
freightRepository.add(f);
}
var allVehicles = new ArrayList<Vehicle>(vals);
for (int i = 0; i < vals; i++) {
var v = new Vehicle(i);
allVehicles.add(v);
var v = new Vehicle(i, "", "", "", 0, 0, null);
vehicleRepository.add(v);
}
for (int i = 0; i < vals; i++)
routeRepository.add(new Route(i, allVehicles.get(i), List.of(allFreights.get(i))));
routeRepository.add(new Route(i, i, List.of((long)i), "", "", 0., 0., null));
}
public void clear() {

View File

@@ -25,7 +25,14 @@ public final class RouteService {
public Route add(Route route) {
int id = repo.add(route);
return new Route(id, route.vehicle(), route.freights());
return new Route(id,
route.vehicleId(),
route.freightId(),
route.startLocation(),
route.endLocation(),
route.distanceKm(),
route.estimatedDurationHours(),
route.status());
}
public @Nullable Route getByFreightId(long freightId) {
@@ -37,7 +44,14 @@ public final class RouteService {
}
public Route update(long id, Route newRoute) {
newRoute = new Route(id, newRoute.vehicle(), newRoute.freights());
newRoute = new Route(id,
newRoute.vehicleId(),
newRoute.freightId(),
newRoute.startLocation(),
newRoute.endLocation(),
newRoute.distanceKm(),
newRoute.estimatedDurationHours(),
newRoute.status());
return repo.update(id, newRoute);
}

View File

@@ -21,7 +21,7 @@ public final class VehicleService {
public Vehicle add(Vehicle veh) {
int id = repo.add(veh);
return new Vehicle(id);
return new Vehicle(id, veh.brand(), veh.model(), veh.licensePlate(), veh.year(), veh.capacityKg(), veh.status());
}
public @Nullable Vehicle getById(long id) {
@@ -29,7 +29,13 @@ public final class VehicleService {
}
public Vehicle update(long id, Vehicle newVehicle) {
newVehicle = new Vehicle(id);
newVehicle = new Vehicle(id,
newVehicle.brand(),
newVehicle.model(),
newVehicle.licensePlate(),
newVehicle.year(),
newVehicle.capacityKg(),
newVehicle.status());
return repo.update(id, newVehicle);
}

View File

@@ -109,8 +109,7 @@ class FreightIntegrationTest {
@MethodSource("mediaTypesClientConsumesProduces")
void add(MediaType clientConsumes, MediaType clientProduces) {
// given
final var newEntity = new Freight(444);
final var expected = new Freight(AMOUNT + 1);
final var newEntity = new Freight(444, "123", "321", 123, new Freight.Dimensions(123, 321, 444), Freight.Status.DELIVERED);
// when
// then
@@ -122,7 +121,12 @@ class FreightIntegrationTest {
.expectStatus()
.isOk()
.expectBody(Freight.class)
.isEqualTo(expected);
.value(val -> {
then(val.id()).isEqualTo(AMOUNT+1);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@@ -130,7 +134,7 @@ class FreightIntegrationTest {
void update(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT-1;
final var newEntity = new Freight(432);
final var newEntity = new Freight(444, "123", "321", 123, new Freight.Dimensions(123, 321, 444), Freight.Status.DELIVERED);
// when
// then
@@ -156,7 +160,7 @@ class FreightIntegrationTest {
void updatePatch(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT-1;
final var newEntity = new Freight(432);
final var newEntity = new Freight(439, "123", "321", 123, new Freight.Dimensions(123, 321, 444), Freight.Status.DELIVERED);
// when
// then

View File

@@ -89,10 +89,7 @@ class RouteIntegrationTest {
})
.value(val -> then(val).isNotNull()
.hasSize(1)
.allSatisfy(route -> {
then(route.vehicle()).isNotNull();
then(route.vehicle().id()).isEqualTo(vehicleId);
}));
.allSatisfy(route -> then(route.vehicleId()).isEqualTo(vehicleId)));
}
@ParameterizedTest
@@ -129,10 +126,10 @@ class RouteIntegrationTest {
})
.value(val -> then(val).isNotNull()
.hasSize(1)
.allSatisfy(route -> then(route.freights())
.allSatisfy(route -> then(route.freightId())
.isNotNull()
.isNotEmpty()
.anySatisfy(fre -> then(fre.id()).isEqualTo(freightId))));
.anySatisfy(fre -> then(fre).isEqualTo(freightId))));
}
@ParameterizedTest
@@ -191,10 +188,7 @@ class RouteIntegrationTest {
@MethodSource("mediaTypesClientConsumesProduces")
void add(MediaType clientConsumes, MediaType clientProduces) {
// given
var veh = new Vehicle(1);
var fre = List.of(new Freight(1));
final var newEntity = new Route(444, veh, fre);
final var expected = new Route(AMOUNT + 1, veh, fre);
final var newEntity = new Route(444, 1, List.of(1L), "123", "321", 1., 2., Route.Status.IN_PROGRESS);
// when
// then
@@ -206,7 +200,12 @@ class RouteIntegrationTest {
.expectStatus()
.isOk()
.expectBody(Route.class)
.isEqualTo(expected);
.value(val -> {
then(val.id()).isEqualTo(AMOUNT+1);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@@ -214,7 +213,7 @@ class RouteIntegrationTest {
void update(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT - 1;
final var newEntity = new Route(432, new Vehicle(1), List.of(new Freight(1)));
final var newEntity = new Route(432, 1, List.of(1L), "123", "321", 22., 44., Route.Status.PLANNED);
// when
// then
@@ -227,7 +226,6 @@ class RouteIntegrationTest {
.isOk()
.expectBody(Route.class)
.value(val -> {
then(val).isNotNull();
then(val.id()).isEqualTo(existingEntityId);
then(val).usingRecursiveComparison()
.ignoringFields("id")
@@ -240,7 +238,7 @@ class RouteIntegrationTest {
void updatePatch(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT - 1;
final var newEntity = new Route(432, new Vehicle(1), List.of(new Freight(1)));
final var newEntity = new Route(432, 1, List.of(1L), "123", "321", 22., 44., Route.Status.CANCELLED);
// when
// then

View File

@@ -109,8 +109,7 @@ class VehicleIntegrationTest {
@MethodSource("mediaTypesClientConsumesProduces")
void add(MediaType clientConsumes, MediaType clientProduces) {
// given
final var newEntity = new Vehicle(444);
final var expected = new Vehicle(AMOUNT + 1);
final var newEntity = new Vehicle(444, "123", "321", "123321", 2001, 15000, Vehicle.Status.MAINTENANCE);
// when
// then
@@ -122,7 +121,12 @@ class VehicleIntegrationTest {
.expectStatus()
.isOk()
.expectBody(Vehicle.class)
.isEqualTo(expected);
.value(val -> {
then(val.id()).isEqualTo(AMOUNT+1);
then(val).usingRecursiveComparison()
.ignoringFields("id")
.isEqualTo(newEntity);
});
}
@ParameterizedTest
@@ -130,7 +134,7 @@ class VehicleIntegrationTest {
void update(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT-1;
final var newEntity = new Vehicle(432);
final var newEntity = new Vehicle(444, "123", "321", "123321", 2001, 15000, Vehicle.Status.MAINTENANCE);
// when
// then
@@ -156,7 +160,7 @@ class VehicleIntegrationTest {
void updatePatch(MediaType clientConsumes, MediaType clientProduces) {
// given
final var existingEntityId = AMOUNT-1;
final var newEntity = new Vehicle(432);
final var newEntity = new Vehicle(444, "123", "321", "123321", 2001, 15000, Vehicle.Status.MAINTENANCE);
// when
// then