S8 apply Palantir formatting to maintained Java sources
All checks were successful
Build, test and publish / verify (push) Successful in 1m39s

This commit is contained in:
2026-10-30 15:50:51 +01:00
parent 327a365550
commit 7e8b3a2cc7
55 changed files with 1907 additions and 713 deletions

View File

@@ -1,15 +1,16 @@
package nl.agilecc.agiledancers;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
private static final String ROOT = "nl.agilecc.agiledancers";
@@ -18,85 +19,128 @@ class ArchitectureTest {
// Exclude Lombok builders and compiler-generated enum-switch helper classes.
@ArchTest
static final ArchRule handwritten_types_are_top_level = classes()
.that().areNotAnnotatedWith("lombok.Generated")
.and().doNotHaveModifier(JavaModifier.SYNTHETIC)
.should().beTopLevelClasses();
.that()
.areNotAnnotatedWith("lombok.Generated")
.and()
.doNotHaveModifier(JavaModifier.SYNTHETIC)
.should()
.beTopLevelClasses();
@ArchTest
static final ArchRule service_records_reside_in_service_model = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areRecords()
.should().resideInAPackage(ROOT + ".service.model");
.that()
.resideInAPackage(ROOT + ".service..")
.and()
.areRecords()
.should()
.resideInAPackage(ROOT + ".service.model");
@ArchTest
static final ArchRule service_interfaces_reside_in_service_port = classes()
.that().resideInAPackage(ROOT + ".service..")
.and().areInterfaces()
.should().resideInAPackage(ROOT + ".service.port");
.that()
.resideInAPackage(ROOT + ".service..")
.and()
.areInterfaces()
.should()
.resideInAPackage(ROOT + ".service.port");
@ArchTest
static final ArchRule scheduling_port_uses_domain_types = classes()
.that().resideInAPackage(ROOT + ".service.port..")
.should().onlyDependOnClassesThat()
.that()
.resideInAPackage(ROOT + ".service.port..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".service.port..", ROOT + ".model..", "java..");
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
@ArchTest
static final ArchRule api_models_reside_in_the_api_model_package = classes()
.that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should().resideInAPackage(API_MODEL);
.that()
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
.that().resideInAPackage(ROOT + ".rest..")
.and().areRecords()
.should().resideInAPackage(API_MODEL);
.that()
.resideInAPackage(ROOT + ".rest..")
.and()
.areRecords()
.should()
.resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule api_models_are_public_top_level_types = classes()
.that().resideInAPackage(API_MODEL + "..")
.should().bePublic()
.andShould().beTopLevelClasses()
.andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
.that()
.resideInAPackage(API_MODEL + "..")
.should()
.bePublic()
.andShould()
.beTopLevelClasses()
.andShould()
.haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
// The existing services depend on concrete repositories. This rule preserves that
// arrangement until repository ports and dependency injection are introduced.
@ArchTest
static final ArchRule layer_dependencies = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Model").definedBy(ROOT + ".model..")
.layer("Services").definedBy(ROOT + ".service..")
.layer("Repositories").definedBy(ROOT + ".repository..")
.optionalLayer("REST").definedBy(ROOT + ".rest..")
.optionalLayer("Solver").definedBy(ROOT + ".solver..")
.whereLayer("Model").mayNotAccessAnyLayer()
.whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories")
.whereLayer("Repositories").mayOnlyAccessLayers("Model")
.whereLayer("REST").mayOnlyAccessLayers("Services", "Model")
.whereLayer("Solver").mayOnlyAccessLayers("Services", "Model");
.layer("Model")
.definedBy(ROOT + ".model..")
.layer("Services")
.definedBy(ROOT + ".service..")
.layer("Repositories")
.definedBy(ROOT + ".repository..")
.optionalLayer("REST")
.definedBy(ROOT + ".rest..")
.optionalLayer("Solver")
.definedBy(ROOT + ".solver..")
.whereLayer("Model")
.mayNotAccessAnyLayer()
.whereLayer("Services")
.mayOnlyAccessLayers("Model", "Repositories")
.whereLayer("Repositories")
.mayOnlyAccessLayers("Model")
.whereLayer("REST")
.mayOnlyAccessLayers("Services", "Model")
.whereLayer("Solver")
.mayOnlyAccessLayers("Services", "Model");
@ArchTest
static final ArchRule production_classes_have_an_architectural_home = classes()
.should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..",
ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver..");
.should()
.resideInAnyPackage(
ROOT + ".model..",
ROOT + ".service..",
ROOT + ".repository..",
ROOT + ".rest..",
ROOT + ".solver..");
@ArchTest
static final ArchRule model_is_independent_of_frameworks = classes()
.that().resideInAPackage(ROOT + ".model..")
.should().onlyDependOnClassesThat()
.that()
.resideInAPackage(ROOT + ".model..")
.should()
.onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
@ArchTest
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
.that().resideOutsideOfPackage(ROOT + ".solver..")
.should().dependOnClassesThat().resideInAPackage("com.google.ortools..");
.that()
.resideOutsideOfPackage(ROOT + ".solver..")
.should()
.dependOnClassesThat()
.resideInAPackage("com.google.ortools..");
@ArchTest
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
.that().resideOutsideOfPackage(ROOT + ".rest..")
.should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
.that()
.resideOutsideOfPackage(ROOT + ".rest..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
@ArchTest
static final ArchRule packages_are_free_of_cycles = slices()
.matching(ROOT + ".(*)..").should().beFreeOfCycles();
static final ArchRule packages_are_free_of_cycles =
slices().matching(ROOT + ".(*)..").should().beFreeOfCycles();
}

View File

@@ -1,18 +1,15 @@
package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@QuarkusTest
class HealthTest {
@Test
void applicationIsReady() {
given()
.when().get("/q/health/ready")
.then().statusCode(200)
.body("status", equalTo("UP"));
given().when().get("/q/health/ready").then().statusCode(200).body("status", equalTo("UP"));
}
}

View File

@@ -1,54 +1,80 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingTest {
private final SchoolManagementService service = new SchoolManagementService();
private final long school = service.createSchool("Studio").getId();
private List<DayPart> parts(int n) {
return java.util.stream.IntStream.range(0, n).mapToObj(i -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i)).type(DayPartType.AFTERNOON).danceGroups(List.of()).build()).toList();
return java.util.stream.IntStream.range(0, n)
.mapToObj(i -> DayPart.builder()
.date(LocalDate.of(2027, 1, 1).plusDays(i))
.type(DayPartType.AFTERNOON)
.danceGroups(List.of())
.build())
.toList();
}
private DanceGroup group(DanceGroupType type) {
return service.createGroup(school, "Group", type, 0, 99, Set.of());
}
private void run(Show show) {
var input = service.beginScheduling(school, show.getId());
try { service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input)); }
finally { service.endScheduling(show.getId()); }
try {
service.finishScheduling(school, input, new OrToolsSchedulingEngine().solve(input));
} finally {
service.endScheduling(show.getId());
}
}
@Test void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
@Test
void exactAppearancesAndOptimalBalanceIncludingEmptyParts() {
group(DanceGroupType.PRE_SCHOOLER);
var show = service.createShow(school, "Show", parts(6), null);
run(show);
var loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
assertEquals(1, loads.getSum()); assertEquals(0, loads.getMin()); assertEquals(1, loads.getMax());
var loads = show.getDayParts().stream()
.mapToInt(p -> p.getDanceGroups().size())
.summaryStatistics();
assertEquals(1, loads.getSum());
assertEquals(0, loads.getMin());
assertEquals(1, loads.getMax());
for (var type : DanceGroupType.values()) group(type);
run(show);
for (var group : service.groups(school)) assertEquals(show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream().filter(p -> p.getDanceGroups().contains(group)).count());
loads = show.getDayParts().stream().mapToInt(p -> p.getDanceGroups().size()).summaryStatistics();
for (var group : service.groups(school))
assertEquals(
show.getAppearances().get(group.getType()).longValue(),
show.getDayParts().stream()
.filter(p -> p.getDanceGroups().contains(group))
.count());
loads = show.getDayParts().stream()
.mapToInt(p -> p.getDanceGroups().size())
.summaryStatistics();
assertTrue(loads.getMax() - loads.getMin() <= 1);
var snapshot = service.exportSchool(school);
assertEquals(snapshot, SchoolSnapshot.of(snapshot.restore()));
}
@Test void defaultsInitialConfigurationAndPreservationOnEdits() {
@Test
void defaultsInitialConfigurationAndPreservationOnEdits() {
var show = service.createShow(school, "Show", List.of(), null);
assertTrue(show.getAppearances().isEmpty());
service.updateShow(school, show.getId(), "Show", parts(3), null);
assertEquals(3, show.getAppearances().get(DanceGroupType.PRE_EDUCATION));
assertEquals(1, show.getAppearances().get(DanceGroupType.PRE_SCHOOLER));
var counts = new EnumMap<>(show.getAppearances()); counts.put(DanceGroupType.PRE_SCHOOLER, 2);
var counts = new EnumMap<>(show.getAppearances());
counts.put(DanceGroupType.PRE_SCHOOLER, 2);
service.updateShow(school, show.getId(), "Show", parts(4), counts);
assertEquals(counts, show.getAppearances());
assertThrows(ManagementException.class, () -> service.updateShow(school, show.getId(), "Show", parts(1), null));
@@ -58,19 +84,25 @@ class SchedulingTest {
service.updateShow(school, show.getId(), "Show", parts(4), null);
assertEquals(counts, show.getAppearances());
}
@Test void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
@Test
void rejectsDuplicatePartsMissingCountsAndOutOfRangeCounts() {
var duplicate = List.of(parts(1).getFirst(), parts(1).getFirst());
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", duplicate, null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(7), null));
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), Map.of()));
var counts = ShowConfiguration.defaults(2); counts.put(DanceGroupType.REGULAR, 3);
var counts = ShowConfiguration.defaults(2);
counts.put(DanceGroupType.REGULAR, 3);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
counts.put(DanceGroupType.REGULAR, 0);
assertThrows(ManagementException.class, () -> service.createShow(school, "Show", parts(2), counts));
}
@Test void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
@Test
void duplicateRunsStaleResultsAndMalformedResultsPreserveAssignments() {
var group = group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null); run(show);
var show = service.createShow(school, "Show", parts(2), null);
run(show);
var before = show.getDayParts().stream().map(DayPart::getDanceGroups).toList();
var input = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
@@ -79,18 +111,25 @@ class SchedulingTest {
assertDoesNotThrow(() -> service.finishScheduling(school, input, result));
group(DanceGroupType.PRE_SCHOOLER);
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
service.endScheduling(show.getId());
var fresh = service.beginScheduling(school, show.getId());
assertThrows(ManagementException.class, () -> service.finishScheduling(school, fresh,
new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
assertThrows(
ManagementException.class,
() -> service.finishScheduling(
school, fresh, new SchedulingResult(false, List.of(List.of(), List.of()))));
assertEquals(
before, show.getDayParts().stream().map(DayPart::getDanceGroups).toList());
// Feasible results are accepted without requiring proof of optimality.
var valid = new OrToolsSchedulingEngine().solve(fresh);
assertDoesNotThrow(() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
assertDoesNotThrow(
() -> service.finishScheduling(school, fresh, new SchedulingResult(false, valid.assignments())));
service.endScheduling(show.getId());
}
@Test void staleDaypartCountChangesAndDeletedShowsAreRejected() {
@Test
void staleDaypartCountChangesAndDeletedShowsAreRejected() {
group(DanceGroupType.REGULAR);
var show = service.createShow(school, "Show", parts(2), null);
var input = service.beginScheduling(school, show.getId());
@@ -101,7 +140,9 @@ class SchedulingTest {
assertThrows(ManagementException.class, () -> service.finishScheduling(school, input, result));
service.endScheduling(show.getId());
}
@Test void needsGroupsAndDayparts() {
@Test
void needsGroupsAndDayparts() {
var show = service.createShow(school, "Show", parts(1), null);
assertThrows(ManagementException.class, () -> service.beginScheduling(school, show.getId()));
group(DanceGroupType.REGULAR);

View File

@@ -1,19 +1,21 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import org.junit.jupiter.api.Test;
@QuarkusTest
class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools";
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "dayParts", new Object[]{Map.of("date", "2027-06-12", "type", "AFTERNOON")});
private static final Map<String, Object> GROUP = Map.of(
"category", "Teens", "type", "REGULAR", "startAge", 12, "endAge", 18, "styles", new String[] {"HIPHOP"});
private static final Map<String, Object> SHOW =
Map.of("name", "Annual show", "dayParts", new Object[] {Map.of("date", "2027-06-12", "type", "AFTERNOON")});
@Test
void managesSchoolAndItsResources() {
@@ -27,37 +29,64 @@ class SchoolManagementResourceTest {
long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
.statusCode(200)
.body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
long groupId = create(school + "/dance-groups", GROUP);
String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
.statusCode(200).body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
put(
group,
Map.of(
"category",
"Selection",
"type",
"SELECTION",
"startAge",
10,
"endAge",
20,
"styles",
new String[] {"JAZZ", "MODERN"}))
.statusCode(200)
.body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId))
.body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
given().get(show)
.then()
.statusCode(200)
.body("dayParts.type", contains("AFTERNOON"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "dayParts", new Object[]{Map.of("date", "2027-07-01", "type", "EVENING")}))
.statusCode(200).body("dayParts.date", contains("2027-07-01"));
put(show, Map.of("name", "Summer show", "dayParts", new Object[] {
Map.of("date", "2027-07-01", "type", "EVENING")
}))
.statusCode(200)
.body("dayParts.date", contains("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
given().get(school)
.then()
.statusCode(200)
.body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId))
.body("showIds", contains((int) showId));
for (String path : new String[]{dancer, group, show}) {
for (String path : new String[] {dancer, group, show}) {
given().delete(path).then().statusCode(204);
given().get(path).then().statusCode(404);
given().delete(path).then().statusCode(404);
}
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
given().get(school)
.then()
.body("dancerIds", empty())
.body("danceGroupIds", empty())
.body("showIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
@@ -71,7 +100,7 @@ class SchoolManagementResourceTest {
long dancerId = create(school + "/dancers", DANCER);
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (String group : new String[]{first, second}) {
for (String group : new String[] {first, second}) {
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().get(group).then().body("dancerIds", contains((int) dancerId));
@@ -98,14 +127,16 @@ class SchoolManagementResourceTest {
long group = create(first + "/dance-groups", GROUP);
long show = create(first + "/shows", SHOW);
long otherGroup = create(second + "/dance-groups", GROUP);
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
for (String path : new String[] {"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
given().get(second + path).then().statusCode(404);
given().delete(second + path).then().statusCode(404);
}
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
put(second + "/shows/" + show, SHOW).statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer)
.then()
.statusCode(404);
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
@@ -121,30 +152,66 @@ class SchoolManagementResourceTest {
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json")
.body("null")
.post(SCHOOLS)
.then()
.statusCode(400);
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
try {
put(school, Map.of("name", " ")).statusCode(400);
given().get(school).then().body("name", equalTo("Validation school"));
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
for (Object body : new Object[] {
Map.of("name", "Sam"),
Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dancers")
.then()
.statusCode(400);
}
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
for (String body : new String[] {
"{}",
"{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"
}) {
given().contentType("application/json")
.body(body)
.post(school + "/dance-groups")
.then()
.statusCode(400);
}
long group = create(school + "/dance-groups", GROUP);
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
put(
school + "/dance-groups/" + group,
Map.of(
"category",
"Invalid",
"type",
"REGULAR",
"startAge",
20,
"endAge",
10,
"styles",
new String[] {}))
.statusCode(400);
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
given().contentType("application/json").body(Map.of("name", "Show"))
.post(school + "/shows").then().statusCode(400);
given().contentType("application/json")
.body(Map.of("name", "Show"))
.post(school + "/shows")
.then()
.statusCode(400);
given().get(school + "/dancers").then().body("$", empty());
given().get(school + "/shows").then().body("$", empty());
given().contentType("application/json").body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
given().contentType("application/json")
.body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers")
.then()
.statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
@@ -161,22 +228,44 @@ class SchoolManagementResourceTest {
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
String before = given().get(show).asString();
String counts = "{\"SHOW_CASE\":1,\"PRE_EDUCATION\":1,\"SELECTION\":1,\"REGULAR\":%s,\"PRE_SCHOOLER\":1}";
for (String count : new String[]{"0", "2", "1.5", "null"}) {
String body = "{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":" + counts.formatted(count) + "}";
for (String count : new String[] {"0", "2", "1.5", "null"}) {
String body =
"{\"name\":\"Show\",\"dayParts\":[{\"date\":\"2027-06-12\",\"type\":\"AFTERNOON\"}],\"appearances\":"
+ counts.formatted(count) + "}";
put(show, body).statusCode(400);
given().get(show).then().body(equalTo(before));
}
String exported = given().get(school + "/export").then().statusCode(200).extract().asString();
given().contentType("application/json").body(exported).post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then().statusCode(200);
given().get(school + "/export").then().body("$", equalTo(io.restassured.path.json.JsonPath.from(exported).getMap("$")));
} finally { given().delete(school); }
String exported = given().get(school + "/export")
.then()
.statusCode(200)
.extract()
.asString();
given().contentType("application/json")
.body(exported)
.post(SCHOOLS + "/import?replaceId=" + school.substring(school.lastIndexOf('/') + 1))
.then()
.statusCode(200);
given().get(school + "/export")
.then()
.body(
"$",
equalTo(io.restassured.path.json.JsonPath.from(exported)
.getMap("$")));
} finally {
given().delete(school);
}
}
private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then()
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
.extract().jsonPath().getLong("id");
return given().contentType("application/json")
.body(body)
.post(path)
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/[0-9]+$"))
.extract()
.jsonPath()
.getLong("id");
}
private static ValidatableResponse put(String path, Object body) {

View File

@@ -1,32 +1,68 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class SchoolSnapshotResourceTest {
private static final String BASE = "/api/dance-schools";
@Test void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json").body("{\"name\":\"JSON school\"}").post(BASE)
.then().statusCode(201).extract().jsonPath().getLong("id");
String json = given().get(BASE + "/" + id + "/export").then().statusCode(200)
.header("Content-Disposition", containsString(".json")).body("formatVersion", equalTo(2)).extract().asString();
@Test
void downloadPreviewRestoreAndRejectInvalidFiles() {
long id = given().contentType("application/json")
.body("{\"name\":\"JSON school\"}")
.post(BASE)
.then()
.statusCode(201)
.extract()
.jsonPath()
.getLong("id");
String json = given().get(BASE + "/" + id + "/export")
.then()
.statusCode(200)
.header("Content-Disposition", containsString(".json"))
.body("formatVersion", equalTo(2))
.extract()
.asString();
try {
given().contentType("application/json").body(json).post(BASE + "/import/preview").then().statusCode(200)
.body("existingId", equalTo((int) id)).body("dancers", equalTo(0));
for (String bad : new String[]{"{", "null", json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"), json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")}) {
given().contentType("application/json").body(bad).post(BASE + "/import?replaceId=" + id)
.then().statusCode(400).body("message", not(emptyOrNullString()));
given().contentType("application/json")
.body(json)
.post(BASE + "/import/preview")
.then()
.statusCode(200)
.body("existingId", equalTo((int) id))
.body("dancers", equalTo(0));
for (String bad : new String[] {
"{",
"null",
json.replace("\"formatVersion\" : 2", "\"formatVersion\" : 1"),
json.replace("\"name\" :", "\"unknown\" :"),
json + " {}",
json.replace("\"dancers\" : [ ]", "\"dancers\" : null")
}) {
given().contentType("application/json")
.body(bad)
.post(BASE + "/import?replaceId=" + id)
.then()
.statusCode(400)
.body("message", not(emptyOrNullString()));
}
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
given().delete(BASE + "/" + id).then().statusCode(204);
id = given().contentType("application/json").body(json).post(BASE + "/import")
.then().statusCode(200).extract().jsonPath().getLong("id");
id = given().contentType("application/json")
.body(json)
.post(BASE + "/import")
.then()
.statusCode(200)
.extract()
.jsonPath()
.getLong("id");
given().get(BASE + "/" + id + "/export").then().body(equalTo(json));
} finally { given().delete(BASE + "/" + id); }
} finally {
given().delete(BASE + "/" + id);
}
}
}

View File

@@ -1,31 +1,51 @@
package nl.agilecc.agiledancers;
import nl.agilecc.agiledancers.service.model.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.model.specifications.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.*;
import nl.agilecc.agiledancers.service.model.*;
import org.junit.jupiter.api.Test;
class SchoolSnapshotTest {
private SchoolSnapshot complete() {
return new SchoolSnapshot(2, UUID.randomUUID(), "Studio", List.of(new Person("a", "Sam", LocalDate.of(2010,1,1))),
return new SchoolSnapshot(
2,
UUID.randomUUID(),
"Studio",
List.of(new Person("a", "Sam", LocalDate.of(2010, 1, 1))),
List.of(new Trainer("c", "Alex")),
List.of(new Group("g", "Hiphop", DanceGroupType.REGULAR, 0, 99, List.of(DanceGroupStyle.HIPHOP), List.of("a"), "c"),
List.of(
new Group(
"g",
"Hiphop",
DanceGroupType.REGULAR,
0,
99,
List.of(DanceGroupStyle.HIPHOP),
List.of("a"),
"c"),
new Group("h", "Jazz", DanceGroupType.REGULAR, 0, 99, List.of(), List.of("a"), "c")),
List.of(new Performance("s", "Show",
List.of(new Part(LocalDate.of(2027,1,1), DayPartType.MORNING, List.of("g", "h"))), ShowConfiguration.defaults(1))));
List.of(new Performance(
"s",
"Show",
List.of(new Part(LocalDate.of(2027, 1, 1), DayPartType.MORNING, List.of("g", "h"))),
ShowConfiguration.defaults(1))));
}
@Test void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
@Test
void roundTripAfterRestartPreservesAllFieldsAndSharedRelationships() {
var snapshot = complete();
var service = new SchoolManagementService();
var existing = service.createSchool("Other");
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000,1,1));
service.createDancer(existing.getId(), "Other dancer", LocalDate.of(2000, 1, 1));
var imported = service.importSchool(snapshot, null);
assertEquals(2, imported.getDanceGroups().size());
var dancer = imported.getDancers().iterator().next();
imported.getDanceGroups().forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
imported.getDanceGroups()
.forEach(g -> assertSame(dancer, g.getDancers().iterator().next()));
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(imported.getId()));
var restarted = new SchoolManagementService();
var restored = restarted.importSchool(service.exportSchool(imported.getId()), null);
@@ -36,14 +56,26 @@ class SchoolSnapshotTest {
assertEquals(1, existing.getDancers().size());
var group = imported.getDanceGroups().iterator().next();
service.deleteGroup(imported.getId(), group.getId());
assertEquals(1, imported.getShows().iterator().next().getDayParts().getFirst().getDanceGroups().size());
assertEquals(
1,
imported.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
assertDoesNotThrow(() -> service.exportSchool(imported.getId()).restore());
}
@Test void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService(); var snapshot = complete();
assertNull(service.preview(snapshot).existingId()); assertTrue(service.schools().isEmpty());
@Test
void previewIsReadOnlyAndReplacementRemovesPreviousConfiguration() {
var service = new SchoolManagementService();
var snapshot = complete();
assertNull(service.preview(snapshot).existingId());
assertTrue(service.schools().isEmpty());
var original = service.importSchool(snapshot, null);
service.createDancer(original.getId(), "Extra", LocalDate.of(2000,1,1));
service.createDancer(original.getId(), "Extra", LocalDate.of(2000, 1, 1));
var other = service.createSchool(snapshot.name());
assertEquals(original.getId(), service.preview(snapshot).existingId());
assertThrows(ManagementException.class, () -> service.importSchool(snapshot, null));
@@ -52,20 +84,31 @@ class SchoolSnapshotTest {
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(replaced.getId()));
assertSame(other, service.school(other.getId()));
service.deleteSchool(replaced.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
}
@Test void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService(); var valid = complete();
@Test
void unresolvedReferenceLeavesExistingAndOtherSchoolsIntact() {
var service = new SchoolManagementService();
var valid = complete();
var original = service.importSchool(valid, null);
var bad = new SchoolSnapshot(2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
var bad = new SchoolSnapshot(
2, valid.key(), valid.name(), List.of(), valid.coaches(), valid.groups(), valid.shows());
assertThrows(ManagementException.class, () -> service.preview(bad));
assertThrows(ManagementException.class, () -> service.importSchool(bad, original.getId()));
assertEquals(SchoolSnapshot.of(valid.restore()), service.exportSchool(original.getId()));
}
@Test void emptySchoolRoundTrips() {
@Test
void emptySchoolRoundTrips() {
var service = new SchoolManagementService();
var empty = service.createSchool("Empty"); var snapshot = service.exportSchool(empty.getId());
var empty = service.createSchool("Empty");
var snapshot = service.exportSchool(empty.getId());
service.deleteSchool(empty.getId());
assertEquals(SchoolSnapshot.of(snapshot.restore()), service.exportSchool(service.importSchool(snapshot, null).getId()));
assertEquals(
SchoolSnapshot.of(snapshot.restore()),
service.exportSchool(service.importSchool(snapshot, null).getId()));
}
}

View File

@@ -1,30 +1,44 @@
package nl.agilecc.agiledancers.service;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;
class SchedulingServiceTest {
@Test void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
@Test
void failedAndNoResultRunsPreserveAssignmentsAndReleaseTheRunGuard() {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var show = schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
scheduling.engine = new OrToolsSchedulingEngine();
scheduling.schedule(school, show.getId());
var before = schools.exportSchool(school);
for (SchedulingEngine failure : List.<SchedulingEngine>of(
input -> { throw new IllegalStateException("Native failure"); },
input -> { throw new ManagementException(ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit"); },
input -> {
throw new IllegalStateException("Native failure");
},
input -> {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT, "No valid result before the limit");
},
input -> null)) {
scheduling.engine = failure;
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()));
@@ -34,28 +48,46 @@ class SchedulingServiceTest {
}
}
@Test void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
@Test
void concurrentRunIsRejectedWhileEditsRemainPossibleAndInvalidateTheFirstResult() throws Exception {
var schools = new SchoolManagementService();
long school = schools.createSchool("School").getId();
schools.createGroup(school, "Group", DanceGroupType.REGULAR, 0, 99, Set.of());
var show = schools.createShow(school, "Show", List.of(DayPart.builder().date(LocalDate.of(2027,1,1))
.type(DayPartType.MORNING).danceGroups(List.of()).build()), null);
var scheduling = new SchedulingService(); scheduling.schools = schools;
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
var show = schools.createShow(
school,
"Show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build()),
null);
var scheduling = new SchedulingService();
scheduling.schools = schools;
var entered = new CountDownLatch(1);
var release = new CountDownLatch(1);
scheduling.engine = input -> {
entered.countDown();
try { if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); }
try {
if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("Test timeout");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
return new OrToolsSchedulingEngine().solve(input);
};
try (var executor = Executors.newSingleThreadExecutor()) {
var first = executor.submit(() -> scheduling.schedule(school, show.getId()));
try {
assertTrue(entered.await(10, TimeUnit.SECONDS));
assertEquals(ManagementExceptionKind.CONFLICT, assertThrows(ManagementException.class,
() -> scheduling.schedule(school, show.getId())).getKind());
assertEquals(
ManagementExceptionKind.CONFLICT,
assertThrows(ManagementException.class, () -> scheduling.schedule(school, show.getId()))
.getKind());
schools.createGroup(school, "New group", DanceGroupType.REGULAR, 0, 99, Set.of());
} finally { release.countDown(); }
} finally {
release.countDown();
}
var exception = assertThrows(ExecutionException.class, () -> first.get(10, TimeUnit.SECONDS));
assertInstanceOf(ManagementException.class, exception.getCause());
assertTrue(exception.getCause().getMessage().contains("inputs changed"));