From 4d6df8d16b643772c6189d0db7eb1ff336b75211 Mon Sep 17 00:00:00 2001 From: nitrix Date: Sun, 8 Mar 2026 13:00:30 +0100 Subject: [PATCH] Block RSVPs on expired events with 409 Conflict and inject Clock into RsvpService Adds expiry check to RsvpService using an injected Clock for testability, handles EventExpiredException in GlobalExceptionHandler as 409 Conflict, and adds unit + integration tests using relative dates from a fixed clock. Co-Authored-By: Claude Opus 4.6 --- .../in/web/GlobalExceptionHandler.java | 14 +++++++++ .../fete/application/service/RsvpService.java | 11 ++++++- .../web/EventControllerIntegrationTest.java | 16 ++++++++++ .../application/service/RsvpServiceTest.java | 31 +++++++++++++++++-- 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java index 34c9726..33e3f24 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -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 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 handleEventNotFound( diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java index 65cfe19..0790d17 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -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()); diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index 70c8518..ed1242a 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -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) { diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java index 6503e1f..21e9296 100644 --- a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -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; }