Add school management REST API and architecture checks

This commit is contained in:
2026-10-27 15:02:58 +01:00
parent 2bb3beef60
commit 777d9f199d
28 changed files with 987 additions and 15 deletions

View File

@@ -14,6 +14,14 @@
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
@@ -24,6 +32,11 @@
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>

View File

@@ -5,6 +5,7 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,17 +13,23 @@ import java.util.Set;
@ToString(onlyExplicitlyIncluded = true)
public class DanceGroup extends AbstractEntity {
@ToString.Include
private final String category;
@Setter
private String category;
@ToString.Include
private final DanceGroupType type;
@Setter
private DanceGroupType type;
@ToString.Include
private final int startAge;
@Setter
private int startAge;
@ToString.Include
private final int endAge;
@Setter
private int endAge;
@ToString.Include
private Set<Dancer> dancers;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@ToString.Include
private Set<DanceGroupStyle> styles;
@Builder.Default
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ToString.Include
private Coach coach;
@@ -41,4 +48,13 @@ public class DanceGroup extends AbstractEntity {
public void addStyle(DanceGroupStyle style) {
super.addNullSafe(styles, style);
}
public void removeDancer(Dancer dancer) {
dancers.remove(dancer);
}
public void replaceStyles(Set<DanceGroupStyle> styles) {
this.styles.clear();
this.styles.addAll(styles);
}
}

View File

@@ -2,19 +2,24 @@ package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.Collections;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DanceSchool extends AbstractEntity {
private final String name;
private final Set<Dancer> dancers;
private final Set<DanceGroup> danceGroups;
private final Set<Show> shows;
private final Set<Coach> coaches;
@Setter
private String name;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@Builder.Default
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
@Builder.Default
private final Set<Show> shows = new LinkedHashSet<>();
@Builder.Default
private final Set<Coach> coaches = new LinkedHashSet<>();
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
@@ -47,4 +52,16 @@ public class DanceSchool extends AbstractEntity {
public void addCoach(Coach coach) {
super.addNullSafe(coaches, coach);
}
public void removeDancer(Dancer entity) {
dancers.remove(entity);
}
public void removeDanceGroup(DanceGroup entity) {
danceGroups.remove(entity);
}
public void removeShow(Show entity) {
shows.remove(entity);
}
}

View File

@@ -11,8 +11,10 @@ import java.time.Period;
@ToString(onlyExplicitlyIncluded = true)
public class Dancer extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private String name;
@ToString.Include
@Setter
private LocalDate birthDate;
public int age() {

View File

@@ -3,12 +3,17 @@ package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.List;
import java.time.LocalDate;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private LocalDate date;
@ToString.Include
private final List<DayPart> dayParts;
}

View File

@@ -24,4 +24,7 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
public T findById(long id) {
return entities.get(id);
}
public void delete(long id) {
entities.remove(id);
}
}

View File

@@ -0,0 +1,69 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(GroupView::of).toList();
}
@GET
@Path("/{id}")
public GroupView get(@PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
}
@POST
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(GroupView.of(entity)).build();
}
@PUT
@Path("/{id}")
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteGroup(schoolId, id);
return Response.noContent().build();
}
@PUT
@Path("/{id}/dancers/{dancerId}")
public Response addDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
service.addGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
@DELETE
@Path("/{id}/dancers/{dancerId}")
public Response removeDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
service.removeGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
@Inject
SchoolManagementService service;
@GET
public List<SchoolView> list() {
return service.schools().stream().map(SchoolView::of).toList();
}
@GET
@Path("/{id}")
public SchoolView get(@PathParam("id") long id) {
return SchoolView.of(service.school(id));
}
@POST
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
var entity = service.createSchool(request.name());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(SchoolView.of(entity)).build();
}
@PUT
@Path("/{id}")
public SchoolView update(@PathParam("id") long id, @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteSchool(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,55 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(DancerView::of).toList();
}
@GET
@Path("/{id}")
public DancerView get(@PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
}
@POST
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(DancerView.of(entity)).build();
}
@PUT
@Path("/{id}")
public DancerView update(@PathParam("id") long id, @NotNull @Valid DancerRequest request) {
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteDancer(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.rest;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override
public Response toResponse(ManagementException exception) {
int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case INVALID_INPUT -> 400;
};
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build();
}
}

View File

@@ -0,0 +1,55 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(ShowView::of).toList();
}
@GET
@Path("/{id}")
public ShowView get(@PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id));
}
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), request.date());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(ShowView.of(entity)).build();
}
@PUT
@Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}

View File

@@ -0,0 +1,10 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Dancer;
import java.time.LocalDate;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public record ErrorView(String message) {}

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.Set;
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.List;
import java.util.Set;
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
public static GroupView of(DanceGroup group) {
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
group.getEndAge(), Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
public record SchoolRequest(@NotBlank String name) {}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Show;
import java.util.List;
public record SchoolView(long id, String name, List<Long> dancerIds,
List<Long> danceGroupIds, List<Long> showIds) {
public static SchoolView of(DanceSchool school) {
return new SchoolView(school.getId(), school.getName(),
school.getDancers().stream().map(Dancer::getId).toList(),
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
school.getShows().stream().map(Show::getId).toList());
}
}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.DanceGroup;
import java.time.LocalDate;
import java.util.List;
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
.map(DanceGroup::getId).toList())).toList());
}
}

View File

@@ -0,0 +1,16 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT }
private final Kind kind;
public ManagementException(Kind kind, String message) {
super(message);
this.kind = kind;
}
public Kind getKind() {
return kind;
}
}

View File

@@ -0,0 +1,170 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*;
import java.time.LocalDate;
import java.util.*;
import static nl.agilecc.agiledancers.service.ManagementException.Kind.*;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton
public class SchoolManagementService {
private final DanceSchoolRepository schools = new DanceSchoolRepository();
private final DancerRepository dancers = new DancerRepository();
private final DanceGroupRepository groups = new DanceGroupRepository();
private final ShowRepository shows = new ShowRepository();
public synchronized List<DanceSchool> schools() {
return schools.findAll();
}
public synchronized DanceSchool school(long schoolId) {
return required(schools.findById(schoolId), "Dance school", schoolId);
}
public synchronized DanceSchool createSchool(String name) {
return schools.add(DanceSchool.builder().name(name).build());
}
public synchronized DanceSchool updateSchool(long schoolId, String name) {
DanceSchool school = school(schoolId);
school.setName(name);
return school;
}
public synchronized void deleteSchool(long schoolId) {
DanceSchool school = school(schoolId);
school.getDanceGroups().forEach(group -> groups.delete(group.getId()));
school.getDancers().forEach(dancer -> dancers.delete(dancer.getId()));
school.getShows().forEach(show -> shows.delete(show.getId()));
schools.delete(schoolId);
}
public synchronized List<Dancer> dancers(long schoolId) {
return List.copyOf(school(schoolId).getDancers());
}
public synchronized Dancer dancer(long schoolId, long dancerId) {
return member(school(schoolId).getDancers(), dancerId, "Dancer");
}
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
DanceSchool school = school(schoolId);
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
school.addDancer(dancer);
return dancer;
}
public synchronized Dancer updateDancer(long schoolId, long dancerId, String name, LocalDate birthDate) {
Dancer dancer = dancer(schoolId, dancerId);
dancer.setName(name);
dancer.setBirthDate(birthDate);
return dancer;
}
public synchronized void deleteDancer(long schoolId, long dancerId) {
DanceSchool school = school(schoolId);
Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach(group -> group.removeDancer(dancer));
school.removeDancer(dancer);
dancers.delete(dancerId);
}
public synchronized List<DanceGroup> groups(long schoolId) {
return List.copyOf(school(schoolId).getDanceGroups());
}
public synchronized DanceGroup group(long schoolId, long groupId) {
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
}
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
DanceSchool school = school(schoolId);
validateAgeRange(startAge, endAge);
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
school.addDanceGroup(group);
return group;
}
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
group.setType(type);
group.setStartAge(startAge);
group.setEndAge(endAge);
group.replaceStyles(styles);
return group;
}
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
public synchronized void addGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
group.addDancer(dancer(schoolId, dancerId));
}
public synchronized void removeGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
group.removeDancer(dancer(schoolId, dancerId));
}
public synchronized List<Show> shows(long schoolId) {
return List.copyOf(school(schoolId).getShows());
}
public synchronized Show show(long schoolId, long showId) {
return member(school(schoolId).getShows(), showId, "Show");
}
public synchronized Show createShow(long schoolId, String name, LocalDate date) {
DanceSchool school = school(schoolId);
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
school.addShow(show);
return show;
}
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) {
Show show = show(schoolId, showId);
show.setName(name);
show.setDate(date);
return show;
}
public synchronized void deleteShow(long schoolId, long showId) {
Show show = show(schoolId, showId);
school(schoolId).removeShow(show);
shows.delete(showId);
}
private static void validateAgeRange(int startAge, int endAge) {
if (startAge > endAge) {
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
}
}
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
}
private static <T> T required(T value, String type, long id) {
if (value == null) {
throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
}
return value;
}
}

View File

@@ -0,0 +1,77 @@
package nl.agilecc.agiledancers;
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;
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;
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
private static final String ROOT = "nl.agilecc.agiledancers";
private static final String API_MODEL = ROOT + ".rest.apimodel";
// 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);
@ArchTest
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
.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)$");
// 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");
@ArchTest
static final ArchRule production_classes_have_an_architectural_home = classes()
.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()
.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..");
@ArchTest
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
.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();
}

View File

@@ -0,0 +1,164 @@
package nl.agilecc.agiledancers;
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.*;
@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", "date", "2027-06-12");
@Test
void managesSchoolAndItsResources() {
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
String school = SCHOOLS + "/" + schoolId;
try {
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId));
put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
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"));
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"));
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)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
.statusCode(200).body("date", equalTo("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));
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());
} finally {
given().delete(school).then().statusCode(204);
}
given().get(school).then().statusCode(404);
}
@Test
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try {
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}) {
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));
}
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", contains((int) dancerId));
given().put(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(school + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
}
@Test
void isolatesSchoolsAndCascadesSchoolDeletion() {
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
try {
long dancer = create(first + "/dancers", DANCER);
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}) {
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().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
given().get(first + "/dance-groups").then().statusCode(404);
given().get(first + "/shows").then().statusCode(404);
given().get(second).then().statusCode(200);
} finally {
given().delete(first);
given().delete(second).then().statusCode(204);
}
}
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").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 (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);
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().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);
} finally {
given().delete(school).then().statusCode(204);
}
}
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");
}
private static ValidatableResponse put(String path, Object body) {
return given().contentType("application/json").body(body).put(path).then();
}
}