Add RSVP feature: submit RSVP, block on expired events #16
@@ -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