Add RSVP feature: submit RSVP, block on expired events #16

Merged
nitrix merged 8 commits from 008-rsvp into master 2026-03-08 13:39:36 +01:00
4 changed files with 69 additions and 3 deletions
Showing only changes of commit 4d6df8d16b - Show all commits

View File

@@ -1,5 +1,6 @@
package de.fete.adapter.in.web;
import de.fete.application.service.EventExpiredException;
import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.ExpiryDateInPastException;
import de.fete.application.service.InvalidTimezoneException;
@@ -59,6 +60,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail);
}
/** Handles RSVP on expired event. */
@ExceptionHandler(EventExpiredException.class)
public ResponseEntity<ProblemDetail> handleEventExpired(
EventExpiredException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problemDetail.setTitle("Event Expired");
problemDetail.setType(URI.create("urn:problem-type:event-expired"));
return ResponseEntity.status(HttpStatus.CONFLICT)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(

View File

@@ -8,6 +8,8 @@ import de.fete.domain.port.in.CountAttendeesByEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.LocalDate;
import org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */
@@ -16,13 +18,16 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
private final Clock clock;
/** Creates a new RsvpService. */
public RsvpService(
EventRepository eventRepository,
RsvpRepository rsvpRepository) {
RsvpRepository rsvpRepository,
Clock clock) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.clock = clock;
}
@Override
@@ -30,6 +35,10 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
throw new EventExpiredException(eventToken.value());
}
var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());

View File

@@ -349,6 +349,22 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
}
@Test
void createRsvpForExpiredEventReturns409() throws Exception {
EventJpaEntity event = seedEvent(
"Expired Party", null, "Europe/Berlin",
null, LocalDate.now().minusDays(1));
var request = new CreateRsvpRequest().name("Late Guest");
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-expired"));
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {

View File

@@ -12,6 +12,8 @@ import de.fete.domain.model.OrganizerToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.port.out.EventRepository;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneId;
@@ -28,6 +30,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
class RsvpServiceTest {
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
private static final Instant NOW = Instant.parse("2026-03-08T12:00:00Z");
private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZONE);
private static final LocalDate TODAY = LocalDate.ofInstant(NOW, ZONE);
@Mock
private EventRepository eventRepository;
@@ -39,7 +44,7 @@ class RsvpServiceTest {
@BeforeEach
void setUp() {
rsvpService = new RsvpService(eventRepository, rsvpRepository);
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
}
@Test
@@ -95,6 +100,28 @@ class RsvpServiceTest {
assertThat(result.getName()).isEqualTo("Max");
}
@Test
void createRsvpThrowsWhenEventExpired() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY.minusDays(1));
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
.isInstanceOf(EventExpiredException.class);
}
@Test
void createRsvpThrowsWhenEventExpiresToday() {
var event = buildActiveEvent();
event.setExpiryDate(TODAY);
EventToken token = event.getEventToken();
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest"))
.isInstanceOf(EventExpiredException.class);
}
private Event buildActiveEvent() {
var event = new Event();
event.setId(1L);
@@ -103,7 +130,7 @@ class RsvpServiceTest {
event.setTitle("Test Event");
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
event.setTimezone(ZONE);
event.setExpiryDate(LocalDate.of(2026, 7, 15));
event.setExpiryDate(TODAY.plusDays(30));
event.setCreatedAt(OffsetDateTime.now());
return event;
}