S16 postgresql opslag added
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s

This commit is contained in:
2026-11-04 17:10:25 +01:00
parent f59cad115a
commit 694bf3fa86
89 changed files with 3087 additions and 252 deletions

View File

@@ -0,0 +1,119 @@
package nl.agilecc.agiledancers;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nl.agilecc.agiledancers.model.Coach;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.rest.mapping.DomainViewMapper;
import org.junit.jupiter.api.Test;
@QuarkusTest
class DomainViewMapperTest {
private @Inject DomainViewMapper views;
public @Test void relationshipsBecomeOrderedIdsAndUnlinkedCoachesRetainNull() {
final DanceSchool school =
DanceSchool.builder().name("School").revision(4).build();
school.setId(7L);
final Dancer first = dancer(9L);
final Dancer second = dancer(3L);
school.addDancer(first);
school.addDancer(second);
final SchoolView mapped = views.school(school);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertEquals(4, mapped.revision());
final Coach coach = Coach.builder().name("Coach").dancer(first).build();
coach.setId(2L);
assertEquals(9L, views.coach(coach).dancerId());
coach.setDancer(null);
assertNull(views.coach(coach).dancerId());
school.removeDancer(first);
assertEquals(List.of(9L, 3L), mapped.dancerIds());
assertThrows(
UnsupportedOperationException.class, () -> mapped.dancerIds().clear());
}
public @Test void nestedViewsKeepAssignmentOrderAndExplicitValidationWithoutSharingCollections() {
final DanceGroup first = DanceGroup.builder().category("First").build();
first.setId(8L);
final DanceGroup second = DanceGroup.builder().category("Second").build();
second.setId(2L);
final List<DanceGroup> assigned = new ArrayList<>(List.of(first, second));
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(assigned)
.build();
final List<String> violations = new ArrayList<>(List.of("Draft validation"));
final Show show = Show.builder()
.name("Show")
.dayParts(new ArrayList<>(List.of(part)))
.appearances(Map.of())
.build();
show.setId(1L);
final ShowView mapped = views.show(show, violations);
assertEquals(1, mapped.preferredDayParts());
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assigned.clear();
violations.clear();
show.getDayParts().clear();
assertEquals(List.of(8L, 2L), mapped.dayParts().getFirst().danceGroupIds());
assertEquals(List.of("Draft validation"), mapped.violations());
assertThrows(
UnsupportedOperationException.class,
() -> mapped.dayParts().getFirst().danceGroupIds().clear());
}
public @Test void preferredDefaultsUseTheExistingRuleAndStylesAreSnapshotValues() {
final DayPart part = DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of())
.build();
final Show show = Show.builder()
.dayParts(List.of(part, part))
.appearances(Map.of())
.build();
show.setId(1L);
assertEquals(2, views.show(show, List.of()).preferredDayParts());
show.setPreferredDayParts(1);
assertEquals(1, views.show(show, List.of()).preferredDayParts());
final DanceGroup group = DanceGroup.builder().category("Group").build();
group.setId(1L);
group.addStyle(DanceGroupStyle.JAZZ);
final Dancer dancer = dancer(4L);
group.addDancer(dancer);
final GroupView mapped = views.group(group);
group.replaceStyles(Set.of(DanceGroupStyle.MODERN));
group.removeDancer(dancer);
assertEquals(Set.of(DanceGroupStyle.JAZZ), mapped.styles());
assertEquals(List.of(4L), mapped.dancerIds());
}
private static Dancer dancer(final long id) {
final Dancer dancer = Dancer.builder()
.name("Dancer")
.birthDate(LocalDate.of(2000, 1, 1))
.build();
dancer.setId(id);
return dancer;
}
}

View File

@@ -0,0 +1,386 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.repository.Database;
import nl.agilecc.agiledancers.repository.RunRepository;
import nl.agilecc.agiledancers.repository.SchoolReader;
import nl.agilecc.agiledancers.repository.SchoolWriter;
import nl.agilecc.agiledancers.repository.StorageException;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.SchoolTransactions;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import org.junit.jupiter.api.Test;
@QuarkusTest
class PersistenceTest {
private @Inject SchoolTransactions transactions;
private @Inject SchoolManagementService schools;
private @Inject Database database;
private @Inject SchoolReader reader;
private @Inject SchoolWriter writer;
private @Inject RunRepository runs;
private @Inject jakarta.persistence.EntityManager entities;
public @Test void simultaneousEditsRequireTheLoadedRevision() throws Exception {
final long id = create();
final String path = "/api/dance-schools/" + id;
try (final java.util.concurrent.ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
final java.util.concurrent.Future<Integer> first = executor.submit(() -> rename(path, "First", 0));
final java.util.concurrent.Future<Integer> second = executor.submit(() -> rename(path, "Second", 0));
final List<Integer> statuses = List.of(first.get(10, TimeUnit.SECONDS), second.get(10, TimeUnit.SECONDS));
assertTrue(statuses.containsAll(List.of(200, 409)));
given().contentType("application/json")
.body("{\"name\":\"Missing revision\"}")
.put(path)
.then()
.statusCode(409);
given().get(path + "/data").then().statusCode(200);
assertEquals(1, given().get(path).jsonPath().getInt("revision"));
} finally {
delete(id);
}
}
public @Test void detachedRelationalGraphRetainsIdsReferencesDraftAndFinalState() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
transactions.background(() -> {
final DanceSchool loaded = schools.school(id);
assertEquals(1, loaded.getDancers().size());
assertEquals(
Set.of(DanceGroupStyle.JAZZ),
loaded.getDanceGroups().iterator().next().getStyles());
assertEquals(show.getId(), loaded.getShows().iterator().next().getId());
assertTrue(loaded.getShows().iterator().next().isFinalized());
assertEquals(
loaded.getDanceGroups().iterator().next(),
loaded.getShows()
.iterator()
.next()
.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst());
return null;
});
given().get("/api/dance-schools/" + id + "/export").then().statusCode(200);
} finally {
delete(id);
}
}
public @Test void deferredConstraintFailureRollsBackDomainAndCompletedStatusTogether() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName());
database.transaction(() -> {
runs.save(run);
return null;
});
final java.util.concurrent.atomic.AtomicBoolean published = new java.util.concurrent.atomic.AtomicBoolean();
assertThrows(StorageException.class, () -> damageResult(id, show, run, published));
assertFalse(published.get());
database.transaction(() -> {
assertEquals(
"Persistent school",
reader.load().stream()
.filter((final DanceSchool school) -> school.getId() == id)
.findFirst()
.orElseThrow()
.getName());
assertEquals(
1,
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.size());
assertEquals(
SchedulingState.RUNNING,
runs.load().stream()
.filter((final SchedulingRun item) ->
item.runId().equals(run.runId()))
.findFirst()
.orElseThrow()
.state());
return null;
});
} finally {
delete(id);
}
}
public @Test void recoveryRetainsRunIdentityProgressAndExistingAssignments() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final SchedulingRun run = SchedulingRun.started(id, show.getId(), show.getName())
.progressed(new SchedulingProgress(SchedulingPhase.SPREAD, true));
database.transaction(() -> {
runs.save(run);
return null;
});
database.transaction(() -> {
runs.recover();
return null;
});
database.transaction(() -> {
final SchedulingRun recovered = runs.load().stream()
.filter((final SchedulingRun item) -> item.runId().equals(run.runId()))
.findFirst()
.orElseThrow();
assertEquals(SchedulingState.INTERRUPTED, recovered.state());
assertEquals(run.progress(), recovered.progress());
assertTrue(recovered.finishedAt() != null);
assertTrue(recovered.message().contains("behouden"));
return null;
});
} finally {
delete(id);
}
}
public @Test void aFailedReplacementImportRetainsThePreviousGraphAndRevision() throws Exception {
final long id = create();
try {
fixture(id);
final nl.agilecc.agiledancers.service.model.SchoolSnapshot before =
transactions.background(() -> schools.exportSchool(id));
assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.request(id, 1L, true, () -> {
final DanceSchool imported = schools.importSchool(before, id);
imported.getDancers().iterator().next().setBirthDate(null);
return imported;
}));
transactions.background(() -> {
assertEquals(before, schools.exportSchool(id));
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void changedSchoolInputsRejectAnOlderSolverSnapshot() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final nl.agilecc.agiledancers.model.scheduling.SchedulingInput input =
transactions.background(() -> schools.beginScheduling(id, show.getId()));
transactions.request(id, 1L, true, () -> schools.updateSchool(id, "Changed"));
final nl.agilecc.agiledancers.service.ManagementException failure = assertThrows(
nl.agilecc.agiledancers.service.ManagementException.class,
() -> transactions.background(() -> {
schools.finishScheduling(
id,
input,
new nl.agilecc.agiledancers.model.scheduling.SchedulingResult(
true, List.of(List.of())));
return null;
}));
assertEquals(nl.agilecc.agiledancers.service.ManagementExceptionKind.CONFLICT, failure.getKind());
transactions.background(() -> {
assertEquals(
1,
schools.show(id, show.getId())
.getDayParts()
.getFirst()
.getDanceGroups()
.size());
schools.endScheduling(show.getId());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateVersionRejectsDetachedStaleEntities() throws Exception {
final long id = create();
try {
final nl.agilecc.agiledancers.repository.entity.SchoolEntity stale = database.transaction(
() -> entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id));
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Confirmed");
return null;
});
stale.setName("Stale");
assertThrows(
nl.agilecc.agiledancers.repository.RevisionConflictException.class,
() -> database.transaction(() -> entities.merge(stale)));
transactions.background(() -> {
assertEquals("Confirmed", schools.school(id).getName());
assertEquals(1, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
public @Test void childOnlyEditsAdvanceTheAggregateVersionOnce() throws Exception {
final long id = create();
try {
fixture(id);
transactions.request(id, 1L, true, () -> {
final long dancerId = schools.dancers(id).getFirst().getId();
return schools.updateDancer(id, dancerId, "Updated dancer", LocalDate.of(2000, 1, 1));
});
transactions.background(() -> {
assertEquals(2, schools.school(id).getRevision());
assertEquals("Updated dancer", schools.dancers(id).getFirst().getName());
return null;
});
} finally {
delete(id);
}
}
public @Test void hibernateRetainsAssignmentsWhenDayPartsAreReordered() throws Exception {
final long id = create();
try {
final Show show = fixture(id);
final DayPart added = DayPart.builder()
.date(LocalDate.of(2027, 1, 2))
.type(DayPartType.EVENING)
.danceGroups(List.of())
.build();
transactions.request(
id,
1L,
true,
() -> schools.updateShow(
id,
show.getId(),
"Two parts",
List.of(show.getDayParts().getFirst(), added),
null));
transactions.request(id, 2L, true, () -> {
final Show current = schools.show(id, show.getId());
return schools.updateShow(
id,
show.getId(),
"Swapped",
List.of(
current.getDayParts().getLast(),
current.getDayParts().getFirst()),
null);
});
transactions.background(() -> {
final Show restored = schools.show(id, show.getId());
assertEquals(added.getDate(), restored.getDayParts().getFirst().getDate());
assertEquals(
1, restored.getDayParts().getLast().getDanceGroups().size());
assertEquals(3, schools.school(id).getRevision());
return null;
});
} finally {
delete(id);
}
}
private void damageResult(
final long id,
final Show show,
final SchedulingRun run,
final java.util.concurrent.atomic.AtomicBoolean published)
throws Exception {
database.transaction(() -> {
entities.find(nl.agilecc.agiledancers.repository.entity.SchoolEntity.class, id)
.setName("Uncommitted");
entities.find(
nl.agilecc.agiledancers.repository.entity.DayPartEntity.class,
new nl.agilecc.agiledancers.repository.entity.DayPartId(id, show.getId(), 0))
.getGroupIds()
.clear();
runs.save(run.completed(SchedulingState.OPTIMAL, "Uncommitted", new SchedulingSummary(0, 0, 0)));
database.afterCommit(() -> published.set(true));
// This dancer FK is deferred: work returns normally, and commit itself must fail.
entities.find(
nl.agilecc.agiledancers.repository.entity.GroupEntity.class,
new nl.agilecc.agiledancers.repository.entity.SchoolMemberId(
id,
show.getDayParts()
.getFirst()
.getDanceGroups()
.getFirst()
.getId()))
.getDancerIds()
.add(999999L);
return null;
});
}
private long create() throws Exception {
return (Long) transactions.request(
null,
null,
true,
() -> schools.createSchool("Persistent school").getId());
}
private Show fixture(final long id) throws Exception {
return (Show) transactions.request(id, 0L, true, () -> {
final long dancer =
schools.createDancer(id, "Dancer", LocalDate.of(2000, 1, 1)).getId();
final long group = schools.createGroup(
id, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of(DanceGroupStyle.JAZZ)))
.getId();
schools.addGroupDancer(id, group, dancer);
final Show show = schools.createShow(
id,
"Saved show",
List.of(DayPart.builder()
.date(LocalDate.of(2027, 1, 1))
.type(DayPartType.MORNING)
.danceGroups(List.of(schools.group(id, group)))
.build()),
null);
show.setFinalized(true);
return show;
});
}
private int rename(final String path, final String name, final long revision) {
return given().header("X-School-Revision", revision)
.contentType("application/json")
.body(java.util.Map.of("name", name))
.put(path)
.statusCode();
}
private void delete(final long id) {
transactions.background(() -> {
writer.delete(id);
return null;
});
}
}

View File

@@ -0,0 +1,50 @@
package nl.agilecc.agiledancers;
import io.restassured.RestAssured;
import io.restassured.response.Response;
import io.restassured.specification.FilterableRequestSpecification;
import io.restassured.specification.RequestSpecification;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Existing endpoint scenarios use fresh revisions; concurrency tests deliberately send explicit stale revisions. */
public class RevisionRequests {
private static final Pattern SCHOOL = Pattern.compile("/api/dance-schools/(\\d+)(?:/|$)");
private static final Pattern REPLACEMENT = Pattern.compile("[?&]replaceId=(\\d+)");
public static RequestSpecification given() {
return RestAssured.given()
.filter(
(final FilterableRequestSpecification request,
final io.restassured.specification.FilterableResponseSpecification response,
final io.restassured.filter.FilterContext context) -> {
addRevision(request);
return context.next(request, response);
});
}
private static void addRevision(final FilterableRequestSpecification request) {
final String id = schoolId(request);
if (!request.getMethod().equals("GET")
&& id != null
&& !request.getHeaders().hasHeaderWithName("X-School-Revision")) {
final Response school = RestAssured.given().get("/api/dance-schools/" + id);
if (school.statusCode() == 200) {
request.header("X-School-Revision", school.jsonPath().getLong("revision"));
}
}
}
private static String schoolId(final FilterableRequestSpecification request) {
final Matcher school = SCHOOL.matcher(request.getURI());
if (school.find()) {
return school.group(1);
}
final Matcher replacement = REPLACEMENT.matcher(request.getURI());
if (replacement.find()) {
return replacement.group(1);
}
final Object parameter = request.getQueryParams().get("replaceId");
return parameter == null ? null : parameter.toString();
}
}

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.empty;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -36,12 +36,14 @@ import org.junit.jupiter.api.Test;
class SchedulingEventsTest {
private @Inject SchoolManagementService schools;
private @Inject ObjectMapper json;
private @Inject nl.agilecc.agiledancers.service.SchoolTransactions transactions;
private @TestHTTPResource URI base;
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
final long school = schools.createSchool("SSE school").getId();
final long school = (Long) transactions.request(
null, null, true, () -> schools.createSchool("SSE school").getId());
try {
final Show show = show(school);
final Show show = (Show) transactions.request(school, 0L, true, () -> show(school));
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
given().get(path).then().statusCode(204);
try (final HttpClient client = HttpClient.newHttpClient();
@@ -64,7 +66,7 @@ class SchedulingEventsTest {
}
assertReconnectContains(show.getId());
} finally {
schools.deleteSchool(school);
given().delete("/api/dance-schools/" + school);
}
}

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;

View File

@@ -1,6 +1,6 @@
package nl.agilecc.agiledancers;
import static io.restassured.RestAssured.given;
import static nl.agilecc.agiledancers.RevisionRequests.given;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.emptyOrNullString;
import static org.hamcrest.Matchers.equalTo;

View File

@@ -19,11 +19,11 @@ import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingRun;
import nl.agilecc.agiledancers.model.scheduling.SchedulingState;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import nl.agilecc.agiledancers.service.model.GroupDetails;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.model.SchedulingState;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
import org.junit.jupiter.api.Test;