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