Add RSVP creation endpoint with typed tokens and attendee count
Introduce typed token value objects (EventToken, OrganizerToken,
RsvpToken) and refactor all existing Event code to use them.
Add POST /events/{token}/rsvps endpoint that persists an RSVP and
returns an rsvpToken. Populate attendeeCount in GET /events/{token}
from a real count query instead of hardcoded 0.
Includes: OpenAPI spec, Liquibase migration (rsvps table with
ON DELETE CASCADE), domain model, hexagonal ports/adapters,
service layer, and full test coverage (unit + integration).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,8 +11,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.adapter.in.web.model.CreateEventRequest;
|
||||
import de.fete.adapter.in.web.model.CreateEventResponse;
|
||||
import de.fete.adapter.in.web.model.CreateRsvpRequest;
|
||||
import de.fete.adapter.in.web.model.CreateRsvpResponse;
|
||||
import de.fete.adapter.out.persistence.EventJpaEntity;
|
||||
import de.fete.adapter.out.persistence.EventJpaRepository;
|
||||
import de.fete.adapter.out.persistence.RsvpJpaEntity;
|
||||
import de.fete.adapter.out.persistence.RsvpJpaRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -39,6 +43,9 @@ class EventControllerIntegrationTest {
|
||||
@Autowired
|
||||
private EventJpaRepository jpaRepository;
|
||||
|
||||
@Autowired
|
||||
private RsvpJpaRepository rsvpJpaRepository;
|
||||
|
||||
// --- Create Event tests ---
|
||||
|
||||
@Test
|
||||
@@ -268,6 +275,80 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(jsonPath("$.expired").value(true));
|
||||
}
|
||||
|
||||
// --- RSVP tests ---
|
||||
|
||||
@Test
|
||||
void createRsvpReturns201WithToken() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"RSVP Event", "Join us!", "Europe/Berlin",
|
||||
"Berlin", LocalDate.now().plusDays(30));
|
||||
|
||||
var request = new CreateRsvpRequest().name("Max Mustermann");
|
||||
|
||||
var result = mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.rsvpToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.name").value("Max Mustermann"))
|
||||
.andReturn();
|
||||
|
||||
var response = objectMapper.readValue(
|
||||
result.getResponse().getContentAsString(), CreateRsvpResponse.class);
|
||||
|
||||
RsvpJpaEntity persisted = rsvpJpaRepository
|
||||
.findByRsvpToken(response.getRsvpToken()).orElseThrow();
|
||||
assertThat(persisted.getName()).isEqualTo("Max Mustermann");
|
||||
assertThat(persisted.getEventId()).isEqualTo(event.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpWithBlankNameReturns400() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"RSVP Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
|
||||
var request = new CreateRsvpRequest().name("");
|
||||
|
||||
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void attendeeCountIncreasesAfterRsvp() throws Exception {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Count Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(0));
|
||||
|
||||
var request = new CreateRsvpRequest().name("First Guest");
|
||||
|
||||
mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isCreated());
|
||||
|
||||
mockMvc.perform(get("/api/events/" + event.getEventToken()))
|
||||
.andExpect(jsonPath("$.attendeeCount").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpForUnknownEventReturns404() throws Exception {
|
||||
var request = new CreateRsvpRequest().name("Ghost");
|
||||
|
||||
mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||
}
|
||||
|
||||
private EventJpaEntity seedEvent(
|
||||
String title, String description, String timezone,
|
||||
String location, LocalDate expiryDate) {
|
||||
|
||||
@@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import de.fete.TestcontainersConfig;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.model.OrganizerToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
@@ -47,7 +48,7 @@ class EventPersistenceAdapterTest {
|
||||
|
||||
@Test
|
||||
void findByUnknownEventTokenReturnsEmpty() {
|
||||
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID());
|
||||
Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
|
||||
|
||||
assertThat(found).isEmpty();
|
||||
}
|
||||
@@ -61,8 +62,8 @@ class EventPersistenceAdapterTest {
|
||||
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(UUID.randomUUID());
|
||||
event.setOrganizerToken(UUID.randomUUID());
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle("Full Event");
|
||||
event.setDescription("A detailed description");
|
||||
event.setDateTime(dateTime);
|
||||
@@ -87,8 +88,8 @@ class EventPersistenceAdapterTest {
|
||||
|
||||
private Event buildEvent() {
|
||||
var event = new Event();
|
||||
event.setEventToken(UUID.randomUUID());
|
||||
event.setOrganizerToken(UUID.randomUUID());
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
event.setTitle("Test Event");
|
||||
event.setDescription("Test description");
|
||||
event.setDateTime(OffsetDateTime.now().plusDays(7));
|
||||
|
||||
@@ -9,6 +9,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import de.fete.domain.model.CreateEventCommand;
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
import de.fete.domain.port.out.EventRepository;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -17,7 +18,6 @@ import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -130,7 +130,7 @@ class EventServiceTest {
|
||||
|
||||
@Test
|
||||
void getByEventTokenReturnsEvent() {
|
||||
UUID token = UUID.randomUUID();
|
||||
EventToken token = EventToken.generate();
|
||||
var event = new Event();
|
||||
event.setEventToken(token);
|
||||
event.setTitle("Found Event");
|
||||
@@ -145,7 +145,7 @@ class EventServiceTest {
|
||||
|
||||
@Test
|
||||
void getByEventTokenReturnsEmptyForUnknownToken() {
|
||||
UUID token = UUID.randomUUID();
|
||||
EventToken token = EventToken.generate();
|
||||
when(eventRepository.findByEventToken(token))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import de.fete.domain.model.Event;
|
||||
import de.fete.domain.model.EventToken;
|
||||
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;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RsvpServiceTest {
|
||||
|
||||
private static final ZoneId ZONE = ZoneId.of("Europe/Berlin");
|
||||
private static final Instant FIXED_INSTANT =
|
||||
LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant();
|
||||
private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE);
|
||||
|
||||
@Mock
|
||||
private EventRepository eventRepository;
|
||||
|
||||
@Mock
|
||||
private RsvpRepository rsvpRepository;
|
||||
|
||||
private RsvpService rsvpService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
rsvpService = new RsvpService(eventRepository, rsvpRepository, FIXED_CLOCK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpSucceedsForActiveEvent() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
Rsvp result = rsvpService.createRsvp(token, "Max Mustermann");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Max Mustermann");
|
||||
assertThat(result.getRsvpToken()).isNotNull();
|
||||
assertThat(result.getEventId()).isEqualTo(event.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpPersistsViaRepository() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
rsvpService.createRsvp(token, "Test Guest");
|
||||
|
||||
ArgumentCaptor<Rsvp> captor = ArgumentCaptor.forClass(Rsvp.class);
|
||||
verify(rsvpRepository).save(captor.capture());
|
||||
assertThat(captor.getValue().getName()).isEqualTo("Test Guest");
|
||||
assertThat(captor.getValue().getEventId()).isEqualTo(event.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpThrowsWhenEventNotFound() {
|
||||
EventToken token = EventToken.generate();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> rsvpService.createRsvp(token, "Guest"))
|
||||
.isInstanceOf(EventNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createRsvpTrimsName() {
|
||||
Event event = buildActiveEvent();
|
||||
EventToken token = event.getEventToken();
|
||||
when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event));
|
||||
when(rsvpRepository.save(any(Rsvp.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
Rsvp result = rsvpService.createRsvp(token, " Max ");
|
||||
|
||||
assertThat(result.getName()).isEqualTo("Max");
|
||||
}
|
||||
|
||||
private Event buildActiveEvent() {
|
||||
var event = new Event();
|
||||
event.setId(1L);
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
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.setCreatedAt(OffsetDateTime.now(FIXED_CLOCK));
|
||||
return event;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user