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:
@@ -1,5 +1,6 @@
|
|||||||
package de.fete.adapter.in.web;
|
package de.fete.adapter.in.web;
|
||||||
|
|
||||||
|
import de.fete.application.service.EventExpiredException;
|
||||||
import de.fete.application.service.EventNotFoundException;
|
import de.fete.application.service.EventNotFoundException;
|
||||||
import de.fete.application.service.ExpiryDateInPastException;
|
import de.fete.application.service.ExpiryDateInPastException;
|
||||||
import de.fete.application.service.InvalidTimezoneException;
|
import de.fete.application.service.InvalidTimezoneException;
|
||||||
@@ -59,6 +60,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
|||||||
.body(problemDetail);
|
.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. */
|
/** Handles event not found. */
|
||||||
@ExceptionHandler(EventNotFoundException.class)
|
@ExceptionHandler(EventNotFoundException.class)
|
||||||
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
public ResponseEntity<ProblemDetail> handleEventNotFound(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import de.fete.domain.port.in.CountAttendeesByEventUseCase;
|
|||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.LocalDate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Application service implementing RSVP creation. */
|
/** Application service implementing RSVP creation. */
|
||||||
@@ -16,13 +18,16 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final RsvpRepository rsvpRepository;
|
private final RsvpRepository rsvpRepository;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
/** Creates a new RsvpService. */
|
/** Creates a new RsvpService. */
|
||||||
public RsvpService(
|
public RsvpService(
|
||||||
EventRepository eventRepository,
|
EventRepository eventRepository,
|
||||||
RsvpRepository rsvpRepository) {
|
RsvpRepository rsvpRepository,
|
||||||
|
Clock clock) {
|
||||||
this.eventRepository = eventRepository;
|
this.eventRepository = eventRepository;
|
||||||
this.rsvpRepository = rsvpRepository;
|
this.rsvpRepository = rsvpRepository;
|
||||||
|
this.clock = clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -30,6 +35,10 @@ public class RsvpService implements CreateRsvpUseCase, CountAttendeesByEventUseC
|
|||||||
Event event = eventRepository.findByEventToken(eventToken)
|
Event event = eventRepository.findByEventToken(eventToken)
|
||||||
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
|
||||||
|
|
||||||
|
if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) {
|
||||||
|
throw new EventExpiredException(eventToken.value());
|
||||||
|
}
|
||||||
|
|
||||||
var rsvp = new Rsvp();
|
var rsvp = new Rsvp();
|
||||||
rsvp.setRsvpToken(RsvpToken.generate());
|
rsvp.setRsvpToken(RsvpToken.generate());
|
||||||
rsvp.setEventId(event.getId());
|
rsvp.setEventId(event.getId());
|
||||||
|
|||||||
@@ -349,6 +349,22 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
.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(
|
private EventJpaEntity seedEvent(
|
||||||
String title, String description, String timezone,
|
String title, String description, String timezone,
|
||||||
String location, LocalDate expiryDate) {
|
String location, LocalDate expiryDate) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import de.fete.domain.model.OrganizerToken;
|
|||||||
import de.fete.domain.model.Rsvp;
|
import de.fete.domain.model.Rsvp;
|
||||||
import de.fete.domain.port.out.EventRepository;
|
import de.fete.domain.port.out.EventRepository;
|
||||||
import de.fete.domain.port.out.RsvpRepository;
|
import de.fete.domain.port.out.RsvpRepository;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
@@ -28,6 +30,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||||||
class RsvpServiceTest {
|
class RsvpServiceTest {
|
||||||
|
|
||||||
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
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
|
@Mock
|
||||||
private EventRepository eventRepository;
|
private EventRepository eventRepository;
|
||||||
@@ -39,7 +44,7 @@ class RsvpServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
rsvpService = new RsvpService(eventRepository, rsvpRepository);
|
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -95,6 +100,28 @@ class RsvpServiceTest {
|
|||||||
assertThat(result.getName()).isEqualTo("Max");
|
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() {
|
private Event buildActiveEvent() {
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setId(1L);
|
event.setId(1L);
|
||||||
@@ -103,7 +130,7 @@ class RsvpServiceTest {
|
|||||||
event.setTitle("Test Event");
|
event.setTitle("Test Event");
|
||||||
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
|
||||||
event.setTimezone(ZONE);
|
event.setTimezone(ZONE);
|
||||||
event.setExpiryDate(LocalDate.of(2026, 7, 15));
|
event.setExpiryDate(TODAY.plusDays(30));
|
||||||
event.setCreatedAt(OffsetDateTime.now());
|
event.setCreatedAt(OffsetDateTime.now());
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user