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 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 13:00:30 +01:00
parent be1c5062a2
commit 4d6df8d16b
4 changed files with 69 additions and 3 deletions

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;
}