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:
2026-03-08 11:49:49 +01:00
parent 4828d06aba
commit a625e34fe4
24 changed files with 688 additions and 39 deletions

View File

@@ -3,13 +3,19 @@ package de.fete.adapter.in.web;
import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.api.EventsApi;
import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse; 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.in.web.model.GetEventResponse; import de.fete.adapter.in.web.model.GetEventResponse;
import de.fete.application.service.EventNotFoundException; import de.fete.application.service.EventNotFoundException;
import de.fete.application.service.InvalidTimezoneException; import de.fete.application.service.InvalidTimezoneException;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.CreateRsvpUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.RsvpRepository;
import java.time.Clock; import java.time.Clock;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.time.LocalDate; import java.time.LocalDate;
@@ -25,15 +31,21 @@ public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase; private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase; private final GetEventUseCase getEventUseCase;
private final CreateRsvpUseCase createRsvpUseCase;
private final RsvpRepository rsvpRepository;
private final Clock clock; private final Clock clock;
/** Creates a new controller with the given use cases and clock. */ /** Creates a new controller with the given use cases, repository, and clock. */
public EventController( public EventController(
CreateEventUseCase createEventUseCase, CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase, GetEventUseCase getEventUseCase,
CreateRsvpUseCase createRsvpUseCase,
RsvpRepository rsvpRepository,
Clock clock) { Clock clock) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase; this.getEventUseCase = getEventUseCase;
this.createRsvpUseCase = createRsvpUseCase;
this.rsvpRepository = rsvpRepository;
this.clock = clock; this.clock = clock;
} }
@@ -54,8 +66,8 @@ public class EventController implements EventsApi {
Event event = createEventUseCase.createEvent(command); Event event = createEventUseCase.createEvent(command);
var response = new CreateEventResponse(); var response = new CreateEventResponse();
response.setEventToken(event.getEventToken()); response.setEventToken(event.getEventToken().value());
response.setOrganizerToken(event.getOrganizerToken()); response.setOrganizerToken(event.getOrganizerToken().value());
response.setTitle(event.getTitle()); response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId()); response.setTimezone(event.getTimezone().getId());
@@ -66,23 +78,38 @@ public class EventController implements EventsApi {
@Override @Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) { public ResponseEntity<GetEventResponse> getEvent(UUID token) {
Event event = getEventUseCase.getByEventToken(token) var eventToken = new de.fete.domain.model.EventToken(token);
Event event = getEventUseCase.getByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(token)); .orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse(); var response = new GetEventResponse();
response.setEventToken(event.getEventToken()); response.setEventToken(event.getEventToken().value());
response.setTitle(event.getTitle()); response.setTitle(event.getTitle());
response.setDescription(event.getDescription()); response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId()); response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation()); response.setLocation(event.getLocation());
response.setAttendeeCount(0); response.setAttendeeCount(
(int) rsvpRepository.countByEventId(event.getId()));
response.setExpired( response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock))); event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@Override
public ResponseEntity<CreateRsvpResponse> createRsvp(
UUID token, CreateRsvpRequest createRsvpRequest) {
var eventToken = new EventToken(token);
Rsvp rsvp = createRsvpUseCase.createRsvp(eventToken, createRsvpRequest.getName());
var response = new CreateRsvpResponse();
response.setRsvpToken(rsvp.getRsvpToken().value());
response.setName(rsvp.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
private static ZoneId parseTimezone(String timezone) { private static ZoneId parseTimezone(String timezone) {
try { try {
return ZoneId.of(timezone); return ZoneId.of(timezone);

View File

@@ -1,10 +1,11 @@
package de.fete.adapter.out.persistence; package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event; 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 de.fete.domain.port.out.EventRepository;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the EventRepository outbound port. */ /** Persistence adapter implementing the EventRepository outbound port. */
@@ -26,15 +27,15 @@ public class EventPersistenceAdapter implements EventRepository {
} }
@Override @Override
public Optional<Event> findByEventToken(UUID eventToken) { public Optional<Event> findByEventToken(EventToken eventToken) {
return jpaRepository.findByEventToken(eventToken).map(this::toDomain); return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
} }
private EventJpaEntity toEntity(Event event) { private EventJpaEntity toEntity(Event event) {
var entity = new EventJpaEntity(); var entity = new EventJpaEntity();
entity.setId(event.getId()); entity.setId(event.getId());
entity.setEventToken(event.getEventToken()); entity.setEventToken(event.getEventToken().value());
entity.setOrganizerToken(event.getOrganizerToken()); entity.setOrganizerToken(event.getOrganizerToken().value());
entity.setTitle(event.getTitle()); entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription()); entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime()); entity.setDateTime(event.getDateTime());
@@ -48,8 +49,8 @@ public class EventPersistenceAdapter implements EventRepository {
private Event toDomain(EventJpaEntity entity) { private Event toDomain(EventJpaEntity entity) {
var event = new Event(); var event = new Event();
event.setId(entity.getId()); event.setId(entity.getId());
event.setEventToken(entity.getEventToken()); event.setEventToken(new EventToken(entity.getEventToken()));
event.setOrganizerToken(entity.getOrganizerToken()); event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken()));
event.setTitle(entity.getTitle()); event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription()); event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime()); event.setDateTime(entity.getDateTime());

View File

@@ -0,0 +1,68 @@
package de.fete.adapter.out.persistence;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.UUID;
/** JPA entity mapping to the rsvps table. */
@Entity
@Table(name = "rsvps")
public class RsvpJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "rsvp_token", nullable = false, unique = true)
private UUID rsvpToken;
@Column(name = "event_id", nullable = false)
private Long eventId;
@Column(nullable = false, length = 100)
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public UUID getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(UUID rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,14 @@
package de.fete.adapter.out.persistence;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
/** Spring Data JPA repository for RSVP entities. */
public interface RsvpJpaRepository extends JpaRepository<RsvpJpaEntity, Long> {
/** Finds an RSVP by its token. */
java.util.Optional<RsvpJpaEntity> findByRsvpToken(UUID rsvpToken);
/** Counts RSVPs for the given event. */
long countByEventId(Long eventId);
}

View File

@@ -0,0 +1,48 @@
package de.fete.adapter.out.persistence;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
import de.fete.domain.port.out.RsvpRepository;
import org.springframework.stereotype.Repository;
/** Persistence adapter implementing the RsvpRepository outbound port. */
@Repository
public class RsvpPersistenceAdapter implements RsvpRepository {
private final RsvpJpaRepository jpaRepository;
/** Creates a new adapter with the given JPA repository. */
public RsvpPersistenceAdapter(RsvpJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Rsvp save(Rsvp rsvp) {
RsvpJpaEntity entity = toEntity(rsvp);
RsvpJpaEntity saved = jpaRepository.save(entity);
return toDomain(saved);
}
@Override
public long countByEventId(Long eventId) {
return jpaRepository.countByEventId(eventId);
}
private RsvpJpaEntity toEntity(Rsvp rsvp) {
var entity = new RsvpJpaEntity();
entity.setId(rsvp.getId());
entity.setRsvpToken(rsvp.getRsvpToken().value());
entity.setEventId(rsvp.getEventId());
entity.setName(rsvp.getName());
return entity;
}
private Rsvp toDomain(RsvpJpaEntity entity) {
var rsvp = new Rsvp();
rsvp.setId(entity.getId());
rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken()));
rsvp.setEventId(entity.getEventId());
rsvp.setName(entity.getName());
return rsvp;
}
}

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an RSVP is attempted on an expired event. */
public class EventExpiredException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventExpiredException(UUID eventToken) {
super("Event has expired: " + eventToken);
}
}

View File

@@ -2,6 +2,8 @@ package de.fete.application.service;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.OrganizerToken;
import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.in.GetEventUseCase;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
@@ -9,7 +11,6 @@ import java.time.Clock;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** Application service implementing event creation and retrieval. */ /** Application service implementing event creation and retrieval. */
@@ -32,8 +33,8 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
} }
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle(command.title()); event.setTitle(command.title());
event.setDescription(command.description()); event.setDescription(command.description());
event.setDateTime(command.dateTime()); event.setDateTime(command.dateTime());
@@ -46,7 +47,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
} }
@Override @Override
public Optional<Event> getByEventToken(UUID eventToken) { public Optional<Event> getByEventToken(EventToken eventToken) {
return eventRepository.findByEventToken(eventToken); return eventRepository.findByEventToken(eventToken);
} }
} }

View File

@@ -0,0 +1,43 @@
package de.fete.application.service;
import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
import de.fete.domain.model.RsvpToken;
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 org.springframework.stereotype.Service;
/** Application service implementing RSVP creation. */
@Service
public class RsvpService implements CreateRsvpUseCase {
private final EventRepository eventRepository;
private final RsvpRepository rsvpRepository;
private final Clock clock;
/** Creates a new RsvpService. */
public RsvpService(
EventRepository eventRepository,
RsvpRepository rsvpRepository,
Clock clock) {
this.eventRepository = eventRepository;
this.rsvpRepository = rsvpRepository;
this.clock = clock;
}
@Override
public Rsvp createRsvp(EventToken eventToken, String name) {
Event event = eventRepository.findByEventToken(eventToken)
.orElseThrow(() -> new EventNotFoundException(eventToken.value()));
var rsvp = new Rsvp();
rsvp.setRsvpToken(RsvpToken.generate());
rsvp.setEventId(event.getId());
rsvp.setName(name.strip());
return rsvpRepository.save(rsvp);
}
}

View File

@@ -3,14 +3,13 @@ package de.fete.domain.model;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.util.UUID;
/** Domain entity representing an event. */ /** Domain entity representing an event. */
public class Event { public class Event {
private Long id; private Long id;
private UUID eventToken; private EventToken eventToken;
private UUID organizerToken; private OrganizerToken organizerToken;
private String title; private String title;
private String description; private String description;
private OffsetDateTime dateTime; private OffsetDateTime dateTime;
@@ -29,23 +28,23 @@ public class Event {
this.id = id; this.id = id;
} }
/** Returns the public event token (UUID). */ /** Returns the public event token. */
public UUID getEventToken() { public EventToken getEventToken() {
return eventToken; return eventToken;
} }
/** Sets the public event token. */ /** Sets the public event token. */
public void setEventToken(UUID eventToken) { public void setEventToken(EventToken eventToken) {
this.eventToken = eventToken; this.eventToken = eventToken;
} }
/** Returns the secret organizer token (UUID). */ /** Returns the secret organizer token. */
public UUID getOrganizerToken() { public OrganizerToken getOrganizerToken() {
return organizerToken; return organizerToken;
} }
/** Sets the secret organizer token. */ /** Sets the secret organizer token. */
public void setOrganizerToken(UUID organizerToken) { public void setOrganizerToken(OrganizerToken organizerToken) {
this.organizerToken = organizerToken; this.organizerToken = organizerToken;
} }

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the public event token. */
public record EventToken(UUID value) {
/** Validates that the token value is not null. */
public EventToken {
Objects.requireNonNull(value, "eventToken must not be null");
}
/** Generates a new random event token. */
public static EventToken generate() {
return new EventToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the secret organizer token. */
public record OrganizerToken(UUID value) {
/** Validates that the token value is not null. */
public OrganizerToken {
Objects.requireNonNull(value, "organizerToken must not be null");
}
/** Generates a new random organizer token. */
public static OrganizerToken generate() {
return new OrganizerToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,50 @@
package de.fete.domain.model;
/** Domain entity representing an RSVP. */
public class Rsvp {
private Long id;
private RsvpToken rsvpToken;
private Long eventId;
private String name;
/** Returns the internal database ID. */
public Long getId() {
return id;
}
/** Sets the internal database ID. */
public void setId(Long id) {
this.id = id;
}
/** Returns the RSVP token. */
public RsvpToken getRsvpToken() {
return rsvpToken;
}
/** Sets the RSVP token. */
public void setRsvpToken(RsvpToken rsvpToken) {
this.rsvpToken = rsvpToken;
}
/** Returns the event ID this RSVP belongs to. */
public Long getEventId() {
return eventId;
}
/** Sets the event ID. */
public void setEventId(Long eventId) {
this.eventId = eventId;
}
/** Returns the guest's display name. */
public String getName() {
return name;
}
/** Sets the guest's display name. */
public void setName(String name) {
this.name = name;
}
}

View File

@@ -0,0 +1,18 @@
package de.fete.domain.model;
import java.util.Objects;
import java.util.UUID;
/** Type-safe wrapper for the RSVP token. */
public record RsvpToken(UUID value) {
/** Validates that the token value is not null. */
public RsvpToken {
Objects.requireNonNull(value, "rsvpToken must not be null");
}
/** Generates a new random RSVP token. */
public static RsvpToken generate() {
return new RsvpToken(UUID.randomUUID());
}
}

View File

@@ -0,0 +1,11 @@
package de.fete.domain.port.in;
import de.fete.domain.model.EventToken;
import de.fete.domain.model.Rsvp;
/** Inbound port for creating a new RSVP. */
public interface CreateRsvpUseCase {
/** Creates an RSVP for the given event and guest name. */
Rsvp createRsvp(EventToken eventToken, String name);
}

View File

@@ -1,12 +1,12 @@
package de.fete.domain.port.in; package de.fete.domain.port.in;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** Inbound port for retrieving a public event by its token. */ /** Inbound port for retrieving a public event by its token. */
public interface GetEventUseCase { public interface GetEventUseCase {
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<Event> getByEventToken(UUID eventToken); Optional<Event> getByEventToken(EventToken eventToken);
} }

View File

@@ -1,8 +1,8 @@
package de.fete.domain.port.out; package de.fete.domain.port.out;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
/** Outbound port for persisting and retrieving events. */ /** Outbound port for persisting and retrieving events. */
public interface EventRepository { public interface EventRepository {
@@ -11,5 +11,5 @@ public interface EventRepository {
Event save(Event event); Event save(Event event);
/** Finds an event by its public event token. */ /** Finds an event by its public event token. */
Optional<Event> findByEventToken(UUID eventToken); Optional<Event> findByEventToken(EventToken eventToken);
} }

View File

@@ -0,0 +1,13 @@
package de.fete.domain.port.out;
import de.fete.domain.model.Rsvp;
/** Outbound port for persisting and querying RSVPs. */
public interface RsvpRepository {
/** Persists the given RSVP and returns it with generated fields populated. */
Rsvp save(Rsvp rsvp);
/** Counts the number of RSVPs for the given event. */
long countByEventId(Long eventId);
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="003-create-rsvps-table" author="fete">
<createTable tableName="rsvps">
<column name="id" type="bigserial" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="rsvp_token" type="uuid">
<constraints nullable="false" unique="true"/>
</column>
<column name="event_id" type="bigint">
<constraints nullable="false"
foreignKeyName="fk_rsvps_event_id"
references="events(id)"
deleteCascade="true"/>
</column>
<column name="name" type="varchar(100)">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="rsvps" indexName="idx_rsvps_event_id">
<column name="event_id"/>
</createIndex>
<createIndex tableName="rsvps" indexName="idx_rsvps_rsvp_token">
<column name="rsvp_token"/>
</createIndex>
</changeSet>
</databaseChangeLog>

View File

@@ -8,5 +8,6 @@
<include file="db/changelog/000-baseline.xml"/> <include file="db/changelog/000-baseline.xml"/>
<include file="db/changelog/001-create-events-table.xml"/> <include file="db/changelog/001-create-events-table.xml"/>
<include file="db/changelog/002-add-timezone-column.xml"/> <include file="db/changelog/002-add-timezone-column.xml"/>
<include file="db/changelog/003-create-rsvps-table.xml"/>
</databaseChangeLog> </databaseChangeLog>

View File

@@ -37,6 +37,52 @@ paths:
schema: schema:
$ref: "#/components/schemas/ValidationProblemDetail" $ref: "#/components/schemas/ValidationProblemDetail"
/events/{token}/rsvps:
post:
operationId: createRsvp
summary: Submit an RSVP for an event
tags:
- events
parameters:
- name: token
in: path
required: true
schema:
type: string
format: uuid
description: Public event token
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpRequest"
responses:
"201":
description: RSVP created successfully
content:
application/json:
schema:
$ref: "#/components/schemas/CreateRsvpResponse"
"400":
description: Validation failed (e.g. blank name)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ValidationProblemDetail"
"404":
description: Event not found
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"409":
description: Event has expired — RSVPs no longer accepted
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
/events/{token}: /events/{token}:
get: get:
operationId: getEvent operationId: getEvent
@@ -182,6 +228,34 @@ components:
description: Whether the event's expiry date has passed description: Whether the event's expiry date has passed
example: false example: false
CreateRsvpRequest:
type: object
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 100
description: Guest's display name
example: "Max Mustermann"
CreateRsvpResponse:
type: object
required:
- rsvpToken
- name
properties:
rsvpToken:
type: string
format: uuid
description: Token identifying this RSVP (store client-side for future updates)
example: "d4e5f6a7-b8c9-0123-4567-890abcdef012"
name:
type: string
description: Guest's display name as stored
example: "Max Mustermann"
ProblemDetail: ProblemDetail:
type: object type: object
properties: properties:

View File

@@ -11,8 +11,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import de.fete.TestcontainersConfig; import de.fete.TestcontainersConfig;
import de.fete.adapter.in.web.model.CreateEventRequest; import de.fete.adapter.in.web.model.CreateEventRequest;
import de.fete.adapter.in.web.model.CreateEventResponse; 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.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository; 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.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@@ -39,6 +43,9 @@ class EventControllerIntegrationTest {
@Autowired @Autowired
private EventJpaRepository jpaRepository; private EventJpaRepository jpaRepository;
@Autowired
private RsvpJpaRepository rsvpJpaRepository;
// --- Create Event tests --- // --- Create Event tests ---
@Test @Test
@@ -268,6 +275,80 @@ class EventControllerIntegrationTest {
.andExpect(jsonPath("$.expired").value(true)); .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( private EventJpaEntity seedEvent(
String title, String description, String timezone, String title, String description, String timezone,
String location, LocalDate expiryDate) { String location, LocalDate expiryDate) {

View File

@@ -4,13 +4,14 @@ import static org.assertj.core.api.Assertions.assertThat;
import de.fete.TestcontainersConfig; import de.fete.TestcontainersConfig;
import de.fete.domain.model.Event; 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 de.fete.domain.port.out.EventRepository;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@@ -47,7 +48,7 @@ class EventPersistenceAdapterTest {
@Test @Test
void findByUnknownEventTokenReturnsEmpty() { void findByUnknownEventTokenReturnsEmpty() {
Optional<Event> found = eventRepository.findByEventToken(UUID.randomUUID()); Optional<Event> found = eventRepository.findByEventToken(EventToken.generate());
assertThat(found).isEmpty(); assertThat(found).isEmpty();
} }
@@ -61,8 +62,8 @@ class EventPersistenceAdapterTest {
OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC);
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Full Event"); event.setTitle("Full Event");
event.setDescription("A detailed description"); event.setDescription("A detailed description");
event.setDateTime(dateTime); event.setDateTime(dateTime);
@@ -87,8 +88,8 @@ class EventPersistenceAdapterTest {
private Event buildEvent() { private Event buildEvent() {
var event = new Event(); var event = new Event();
event.setEventToken(UUID.randomUUID()); event.setEventToken(EventToken.generate());
event.setOrganizerToken(UUID.randomUUID()); event.setOrganizerToken(OrganizerToken.generate());
event.setTitle("Test Event"); event.setTitle("Test Event");
event.setDescription("Test description"); event.setDescription("Test description");
event.setDateTime(OffsetDateTime.now().plusDays(7)); event.setDateTime(OffsetDateTime.now().plusDays(7));

View File

@@ -9,6 +9,7 @@ import static org.mockito.Mockito.when;
import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.CreateEventCommand;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.model.EventToken;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import java.time.Clock; import java.time.Clock;
import java.time.Instant; import java.time.Instant;
@@ -17,7 +18,6 @@ import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -130,7 +130,7 @@ class EventServiceTest {
@Test @Test
void getByEventTokenReturnsEvent() { void getByEventTokenReturnsEvent() {
UUID token = UUID.randomUUID(); EventToken token = EventToken.generate();
var event = new Event(); var event = new Event();
event.setEventToken(token); event.setEventToken(token);
event.setTitle("Found Event"); event.setTitle("Found Event");
@@ -145,7 +145,7 @@ class EventServiceTest {
@Test @Test
void getByEventTokenReturnsEmptyForUnknownToken() { void getByEventTokenReturnsEmptyForUnknownToken() {
UUID token = UUID.randomUUID(); EventToken token = EventToken.generate();
when(eventRepository.findByEventToken(token)) when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty()); .thenReturn(Optional.empty());

View File

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