From 4431dc3e93a1132136df20632143901647e3ccee Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 12:39:30 +0200 Subject: [PATCH] test: add recurring reservation and waitlist promotion tests --- .../reservation/RecurringReservationTest.java | 93 +++++++++++++ .../reservation/WaitlistPromotionTest.java | 125 ++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 src/test/java/com/slotline/reservation/RecurringReservationTest.java create mode 100644 src/test/java/com/slotline/reservation/WaitlistPromotionTest.java diff --git a/src/test/java/com/slotline/reservation/RecurringReservationTest.java b/src/test/java/com/slotline/reservation/RecurringReservationTest.java new file mode 100644 index 0000000..a1932bf --- /dev/null +++ b/src/test/java/com/slotline/reservation/RecurringReservationTest.java @@ -0,0 +1,93 @@ +package com.slotline.reservation; + +import com.slotline.common.SlotConflictException; +import com.slotline.organization.Organization; +import com.slotline.organization.OrganizationRepository; +import com.slotline.reservation.dto.RecurringReservationRequest; +import com.slotline.reservation.dto.RecurringReservationResponse; +import com.slotline.reservation.dto.ReservationRequest; +import com.slotline.resource.ResourceService; +import com.slotline.resource.dto.ResourceRequest; +import com.slotline.security.AuthenticatedUser; +import com.slotline.user.Role; +import com.slotline.user.User; +import com.slotline.user.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class RecurringReservationTest { + + @Autowired + private OrganizationRepository organizations; + + @Autowired + private UserRepository users; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ResourceService resourceService; + + @Autowired + private ReservationService reservationService; + + @Autowired + private ReservationRepository reservations; + + @Test + void weeklySeriesExpandsIntoDistinctSlots() { + AuthenticatedUser actor = tenant("recurring-ok"); + Long resourceId = resourceService.create(actor.organizationId(), + new ResourceRequest("Meeting room", "weekly standup")).id(); + + RecurringReservationResponse series = reservationService.createSeries(actor, + new RecurringReservationRequest(resourceId, + Instant.parse("2026-09-07T10:00:00Z"), + Instant.parse("2026-09-07T11:00:00Z"), + "FREQ=WEEKLY;BYDAY=MO;COUNT=3")); + + assertThat(series.count()).isEqualTo(3); + assertThat(series.reservations()).extracting(dto -> dto.startTime()).containsExactly( + Instant.parse("2026-09-07T10:00:00Z"), + Instant.parse("2026-09-14T10:00:00Z"), + Instant.parse("2026-09-21T10:00:00Z")); + assertThat(reservations.findBySeriesIdOrderByStartTimeAsc(series.seriesId())).hasSize(3); + } + + @Test + void conflictingSeriesIsRolledBackWhole() { + AuthenticatedUser actor = tenant("recurring-conflict"); + Long resourceId = resourceService.create(actor.organizationId(), + new ResourceRequest("Lab", "shared bench")).id(); + + reservationService.create(actor, new ReservationRequest(resourceId, + Instant.parse("2026-09-14T10:00:00Z"), + Instant.parse("2026-09-14T11:00:00Z"))); + + assertThatThrownBy(() -> reservationService.createSeries(actor, + new RecurringReservationRequest(resourceId, + Instant.parse("2026-09-07T10:00:00Z"), + Instant.parse("2026-09-07T11:00:00Z"), + "FREQ=WEEKLY;BYDAY=MO;COUNT=3"))) + .isInstanceOf(SlotConflictException.class); + + assertThat(reservations.findByResourceIdAndStatusOrderByStartTimeAsc(resourceId, ReservationStatus.ACTIVE)) + .hasSize(1); + } + + private AuthenticatedUser tenant(String slug) { + Organization organization = organizations.save(new Organization(slug, slug, Instant.now())); + User user = users.save(new User(organization.getId(), slug + "-user", + passwordEncoder.encode("password123"), Role.USER, Instant.now())); + return new AuthenticatedUser(user.getId(), user.getUsername(), user.getRole(), organization.getId()); + } +} diff --git a/src/test/java/com/slotline/reservation/WaitlistPromotionTest.java b/src/test/java/com/slotline/reservation/WaitlistPromotionTest.java new file mode 100644 index 0000000..bb4f954 --- /dev/null +++ b/src/test/java/com/slotline/reservation/WaitlistPromotionTest.java @@ -0,0 +1,125 @@ +package com.slotline.reservation; + +import com.slotline.notification.NotificationChannel; +import com.slotline.notification.NotificationMessage; +import com.slotline.notification.NotificationType; +import com.slotline.organization.Organization; +import com.slotline.organization.OrganizationRepository; +import com.slotline.reservation.dto.ReservationResponse; +import com.slotline.reservation.dto.WaitlistRequest; +import com.slotline.reservation.dto.WaitlistResponse; +import com.slotline.reservation.dto.ReservationRequest; +import com.slotline.resource.ResourceService; +import com.slotline.resource.dto.ResourceRequest; +import com.slotline.security.AuthenticatedUser; +import com.slotline.user.Role; +import com.slotline.user.User; +import com.slotline.user.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class WaitlistPromotionTest { + + private static final Instant SLOT_START = Instant.parse("2026-10-01T09:00:00Z"); + private static final Instant SLOT_END = Instant.parse("2026-10-01T10:00:00Z"); + + @Autowired + private OrganizationRepository organizations; + + @Autowired + private UserRepository users; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private ResourceService resourceService; + + @Autowired + private ReservationService reservationService; + + @Autowired + private WaitlistService waitlistService; + + @Autowired + private RecordingNotificationChannel recording; + + @Test + void cancellingPromotesTheOldestWaiterAndNotifies() { + Organization organization = organizations.save(new Organization("waitlist-org", "waitlist-org", Instant.now())); + AuthenticatedUser holder = user(organization.getId(), "holder"); + AuthenticatedUser waiter = user(organization.getId(), "waiter"); + Long resourceId = resourceService.create(organization.getId(), + new ResourceRequest("Popular court", "single slot")).id(); + + ReservationResponse held = reservationService.create(holder, + new ReservationRequest(resourceId, SLOT_START, SLOT_END)); + waitlistService.join(waiter, new WaitlistRequest(resourceId, SLOT_START, SLOT_END)); + + recording.clear(); + reservationService.cancel(holder, held.id()); + + assertThat(recording.byType(NotificationType.WAITLIST_PROMOTED)) + .anySatisfy(message -> assertThat(message.recipientUserId()).isEqualTo(waiter.id())); + + List waiterReservations = reservationService.listForUser(waiter.id()); + assertThat(waiterReservations) + .anySatisfy(reservation -> { + assertThat(reservation.status()).isEqualTo(ReservationStatus.ACTIVE); + assertThat(reservation.startTime()).isEqualTo(SLOT_START); + }); + + List waiterEntries = waitlistService.listForUser(waiter.id()); + assertThat(waiterEntries).singleElement() + .satisfies(entry -> assertThat(entry.status()).isEqualTo(WaitlistStatus.PROMOTED)); + } + + private AuthenticatedUser user(Long organizationId, String username) { + User user = users.save(new User(organizationId, username, passwordEncoder.encode("password123"), + Role.USER, Instant.now())); + return new AuthenticatedUser(user.getId(), user.getUsername(), user.getRole(), organizationId); + } + + @TestConfiguration + static class RecordingConfig { + + @Bean + RecordingNotificationChannel recordingNotificationChannel() { + return new RecordingNotificationChannel(); + } + } + + static class RecordingNotificationChannel implements NotificationChannel { + + private final List received = new CopyOnWriteArrayList<>(); + + @Override + public String name() { + return "recording"; + } + + @Override + public void deliver(NotificationMessage message) { + received.add(message); + } + + void clear() { + received.clear(); + } + + List byType(NotificationType type) { + return received.stream().filter(message -> message.type() == type).toList(); + } + } +}