diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..605ac2a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,25 @@
+# Changelog
+
+## [1.0] - 2026-10-27
+
+### Added
+
+- Quarkus backend running on Java 25, with health endpoints and Docker support.
+- Domain model for dance schools, dancers, dance groups, coaches, shows and day parts.
+- REST API to create, list, retrieve, update and delete dance schools, dancers, dance groups and shows.
+- Membership management allowing a dancer to belong to multiple groups within a school.
+- Shows with a name, date and three day parts: morning, afternoon and evening.
+- Input validation, school-scoped resource access and cleanup of memberships when dancers are deleted.
+- Shared in-memory storage for the REST API.
+- Separate API model records in `rest.apimodel`.
+- ArchUnit checks for package dependencies, domain independence, package cycles and API model conventions.
+- HTTP integration tests for resource management, memberships, validation and school isolation.
+
+### Fixed
+
+- Adding entities to initially empty dance school and dance group collections now retains them.
+
+### Limitations
+
+- Data is lost when the application stops.
+- Assigning dance groups to show day parts is not yet available.
diff --git a/README.md b/README.md
index a48de3f..ea024a4 100644
--- a/README.md
+++ b/README.md
@@ -42,5 +42,108 @@ curl --fail http://localhost:8080/q/health/ready
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
-The domain services and repositories retain their existing in-memory behavior;
-data is lost when the process stops. Business REST endpoints have not yet been added.
+The REST API uses shared in-memory storage; data is lost when the process stops.
+
+Architecture checks run with the normal test suite, or separately with:
+
+```sh
+mvn -pl agiledancers-be -Dtest=ArchitectureTest test
+```
+
+`ArchitectureTest` guards the following packages below `nl.agilecc.agiledancers`:
+
+| Package | Responsibility | Allowed dependencies within the application |
+| --- | --- | --- |
+| `model` | Domain entities and specifications | Model |
+| `service` | Application use cases and future ports | Model, repositories |
+| `repository` | Current in-memory persistence | Model |
+| `rest` | Future REST services | Services, model |
+| `solver` | Future OR-Tools calculations | Services (ports), model |
+
+REST and solver packages are optional until implemented. OR-Tools dependencies
+are allowed only in `solver`; JAX-RS and RESTEasy dependencies only in `rest`.
+The model may depend only on itself, Java and Lombok. Package cycles and
+production classes outside these packages fail the architecture tests.
+
+API models live in `rest.apimodel`, each in its own Java file as a public top-level
+class or record. Their names end in `Request`, `Response`, `View`, `Dto` or `DTO`.
+Architecture tests enforce this naming and location convention and require all
+records in the REST layer to reside in `rest.apimodel`.
+
+The current services instantiate concrete repositories, so this is not yet strict
+Onion architecture. That requires repository interfaces in the core and injected
+implementations in an outer adapter. For future calculations, define a port in
+`service.port` using domain types and implement it in `solver`; services must not
+depend on solver implementations or expose OR-Tools types.
+
+
+## School management REST API
+
+All endpoints consume and produce JSON. Resources are scoped to their dance school.
+A dancer can belong to multiple dance groups within the same school. Memberships
+are managed separately from group details and survive updates to dancers and groups.
+
+| Resource collection | Item URL |
+| --- | --- |
+| `/api/dance-schools` | `/api/dance-schools/{schoolId}` |
+| `/api/dance-schools/{schoolId}/dancers` | `.../dancers/{dancerId}` |
+| `/api/dance-schools/{schoolId}/dance-groups` | `.../dance-groups/{groupId}` |
+| `/api/dance-schools/{schoolId}/shows` | `.../shows/{showId}` |
+
+Each collection supports `GET` (list) and `POST` (create). Each item supports `GET`,
+`PUT` (replace editable details), and `DELETE`. Creation returns `201` with a
+`Location` header; updates return `200`; deletion returns `204`. Invalid input
+returns `400`, and missing resources or resources belonging to another school
+return `404`. Client-supplied IDs are not used.
+
+Request bodies (dates use ISO `yyyy-MM-dd`):
+
+```json
+{"name": "24 Dance Studio"}
+```
+
+```json
+{"name": "Sam", "birthDate": "2010-05-12"}
+```
+
+```json
+{
+ "category": "Teens",
+ "type": "REGULAR",
+ "startAge": 12,
+ "endAge": 18,
+ "styles": ["HIPHOP", "JAZZ"]
+}
+```
+
+```json
+{"name": "Annual show", "date": "2027-06-12"}
+```
+
+Names and categories must be nonblank. Birth dates cannot be in the future.
+Both ages must be nonnegative and `startAge <= endAge`; `styles` is required
+and may be empty. Group types are `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`,
+`REGULAR`, `PRE_SCHOOLER`. Styles are `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`,
+`CLASSIC`, `BREAKDANCE`.
+
+School responses include `dancerIds`, `danceGroupIds`, and `showIds`; group
+responses include `dancerIds`. To add or remove a dancer's group membership:
+
+```sh
+curl -X PUT http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
+curl -X DELETE http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
+```
+
+Both operations return `204` and are idempotent for existing groups and dancers.
+Deleting a dancer removes all their memberships; deleting a group retains the
+dancers; deleting a school also deletes its dancers, groups and shows.
+
+Shows have a name, a date and three automatically created day parts: `MORNING`,
+`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning
+groups is not exposed yet. Multiple shows per year are allowed.
+
+Run the HTTP integration tests with:
+
+```sh
+mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
+```
diff --git a/agiledancers-be/pom.xml b/agiledancers-be/pom.xml
index 3ef9b6f..d6ae746 100644
--- a/agiledancers-be/pom.xml
+++ b/agiledancers-be/pom.xml
@@ -14,6 +14,14 @@
jar
+
+ io.quarkus
+ quarkus-rest-jackson
+
+
+ io.quarkus
+ quarkus-hibernate-validator
+
io.quarkus
quarkus-smallrye-health
@@ -24,6 +32,11 @@
+
+ com.tngtech.archunit
+ archunit-junit5
+ test
+
io.quarkus
quarkus-junit
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java
index e47612a..ad1f121 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceGroup.java
@@ -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 dancers;
+ @Builder.Default
+ private final Set dancers = new LinkedHashSet<>();
@ToString.Include
- private Set styles;
+ @Builder.Default
+ private final Set 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 styles) {
+ this.styles.clear();
+ this.styles.addAll(styles);
+ }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
index 63803d7..b5b5dae 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/DanceSchool.java
@@ -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 dancers;
- private final Set danceGroups;
- private final Set shows;
- private final Set coaches;
+ @Setter
+ private String name;
+ @Builder.Default
+ private final Set dancers = new LinkedHashSet<>();
+ @Builder.Default
+ private final Set danceGroups = new LinkedHashSet<>();
+ @Builder.Default
+ private final Set shows = new LinkedHashSet<>();
+ @Builder.Default
+ private final Set coaches = new LinkedHashSet<>();
public Set 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);
+ }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java
index a628a44..b3c3ece 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Dancer.java
@@ -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() {
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java
index de61f10..59cac67 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/model/Show.java
@@ -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 dayParts;
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
index 0468b13..bd16561 100644
--- a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/repository/AbstractRepository.java
@@ -24,4 +24,7 @@ public abstract class AbstractRepository {
public T findById(long id) {
return entities.get(id);
}
+ public void delete(long id) {
+ entities.remove(id);
+ }
}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java
new file mode 100644
index 0000000..4b737b6
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceGroupResource.java
@@ -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 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();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java
new file mode 100644
index 0000000..ce9f9d0
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DanceSchoolResource.java
@@ -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 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();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java
new file mode 100644
index 0000000..4872d17
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/DancerResource.java
@@ -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 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();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java
new file mode 100644
index 0000000..07d52e0
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ManagementExceptionMapper.java
@@ -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 {
+ @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();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java
new file mode 100644
index 0000000..22208d6
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/ShowResource.java
@@ -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 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();
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java
new file mode 100644
index 0000000..501d2c1
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerRequest.java
@@ -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) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java
new file mode 100644
index 0000000..0939305
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DancerView.java
@@ -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());
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java
new file mode 100644
index 0000000..6c93251
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/DayPartView.java
@@ -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 danceGroupIds) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ErrorView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ErrorView.java
new file mode 100644
index 0000000..2a0b921
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ErrorView.java
@@ -0,0 +1,6 @@
+package nl.agilecc.agiledancers.rest.apimodel;
+
+import io.quarkus.runtime.annotations.RegisterForReflection;
+
+@RegisterForReflection
+public record ErrorView(String message) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java
new file mode 100644
index 0000000..0a0abbb
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupRequest.java
@@ -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) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java
new file mode 100644
index 0000000..abe79d0
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/GroupView.java
@@ -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 styles, List 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());
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolRequest.java
new file mode 100644
index 0000000..b93d02e
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolRequest.java
@@ -0,0 +1,5 @@
+package nl.agilecc.agiledancers.rest.apimodel;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record SchoolRequest(@NotBlank String name) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java
new file mode 100644
index 0000000..c677b24
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/SchoolView.java
@@ -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 dancerIds,
+ List danceGroupIds, List 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());
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java
new file mode 100644
index 0000000..ef921ef
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowRequest.java
@@ -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) {}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java
new file mode 100644
index 0000000..fde7d1a
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/rest/apimodel/ShowView.java
@@ -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 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());
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java
new file mode 100644
index 0000000..cecedc7
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/ManagementException.java
@@ -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;
+ }
+}
diff --git a/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java
new file mode 100644
index 0000000..1dd8a17
--- /dev/null
+++ b/agiledancers-be/src/main/java/nl/agilecc/agiledancers/service/SchoolManagementService.java
@@ -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 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 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 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 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 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 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 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 member(Collection 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 required(T value, String type, long id) {
+ if (value == null) {
+ throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
+ }
+ return value;
+ }
+}
diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java
new file mode 100644
index 0000000..e5afd7f
--- /dev/null
+++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/ArchitectureTest.java
@@ -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();
+}
diff --git a/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java
new file mode 100644
index 0000000..eb815c8
--- /dev/null
+++ b/agiledancers-be/src/test/java/nl/agilecc/agiledancers/SchoolManagementResourceTest.java
@@ -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 DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
+ private static final Map GROUP = Map.of("category", "Teens", "type", "REGULAR",
+ "startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
+ private static final Map 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();
+ }
+}
diff --git a/pom.xml b/pom.xml
index 348dee4..bdd4768 100644
--- a/pom.xml
+++ b/pom.xml
@@ -28,6 +28,7 @@
1.18.48
+ 1.5.1
2.7.0
@@ -48,6 +49,12 @@
+
+ com.tngtech.archunit
+ archunit-junit5
+ ${archunit.version}
+ test
+
net.datafaker
datafaker