Implement GET /events/{token} backend with timezone support

Domain: add timezone field to Event and CreateEventCommand.
Ports: new GetEventUseCase inbound port.
Service: implement getByEventToken, validate IANA timezone on create.
Controller: map to GetEventResponse, compute expired flag via Clock.
Persistence: timezone column in JPA entity and mapping.
Tests: integration tests use DTOs + ObjectMapper instead of inline JSON,
GET tests seed DB directly via JpaRepository for isolation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 22:33:40 +01:00
parent e77e479e2a
commit e5d0dd5f8f
13 changed files with 391 additions and 99 deletions

View File

@@ -3,9 +3,18 @@ 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.GetEventResponse;
import de.fete.application.service.EventNotFoundException;
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.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.UUID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -15,19 +24,29 @@ import org.springframework.web.bind.annotation.RestController;
public class EventController implements EventsApi { public class EventController implements EventsApi {
private final CreateEventUseCase createEventUseCase; private final CreateEventUseCase createEventUseCase;
private final GetEventUseCase getEventUseCase;
private final Clock clock;
/** Creates a new controller with the given use case. */ /** Creates a new controller with the given use cases and clock. */
public EventController(CreateEventUseCase createEventUseCase) { public EventController(
CreateEventUseCase createEventUseCase,
GetEventUseCase getEventUseCase,
Clock clock) {
this.createEventUseCase = createEventUseCase; this.createEventUseCase = createEventUseCase;
this.getEventUseCase = getEventUseCase;
this.clock = clock;
} }
@Override @Override
public ResponseEntity<CreateEventResponse> createEvent( public ResponseEntity<CreateEventResponse> createEvent(
CreateEventRequest request) { CreateEventRequest request) {
ZoneId zoneId = parseTimezone(request.getTimezone());
var command = new CreateEventCommand( var command = new CreateEventCommand(
request.getTitle(), request.getTitle(),
request.getDescription(), request.getDescription(),
request.getDateTime(), request.getDateTime(),
zoneId,
request.getLocation(), request.getLocation(),
request.getExpiryDate() request.getExpiryDate()
); );
@@ -39,8 +58,36 @@ public class EventController implements EventsApi {
response.setOrganizerToken(event.getOrganizerToken()); response.setOrganizerToken(event.getOrganizerToken());
response.setTitle(event.getTitle()); response.setTitle(event.getTitle());
response.setDateTime(event.getDateTime()); response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setExpiryDate(event.getExpiryDate()); response.setExpiryDate(event.getExpiryDate());
return ResponseEntity.status(HttpStatus.CREATED).body(response); return ResponseEntity.status(HttpStatus.CREATED).body(response);
} }
@Override
public ResponseEntity<GetEventResponse> getEvent(UUID token) {
Event event = getEventUseCase.getByEventToken(token)
.orElseThrow(() -> new EventNotFoundException(token));
var response = new GetEventResponse();
response.setEventToken(event.getEventToken());
response.setTitle(event.getTitle());
response.setDescription(event.getDescription());
response.setDateTime(event.getDateTime());
response.setTimezone(event.getTimezone().getId());
response.setLocation(event.getLocation());
response.setAttendeeCount(0);
response.setExpired(
event.getExpiryDate().isBefore(LocalDate.now(clock)));
return ResponseEntity.ok(response);
}
private static ZoneId parseTimezone(String timezone) {
try {
return ZoneId.of(timezone);
} catch (DateTimeException e) {
throw new InvalidTimezoneException(timezone);
}
}
} }

View File

@@ -1,6 +1,8 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
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 java.net.URI; import java.net.URI;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -57,6 +59,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
.body(problemDetail); .body(problemDetail);
} }
/** Handles event not found. */
@ExceptionHandler(EventNotFoundException.class)
public ResponseEntity<ProblemDetail> handleEventNotFound(
EventNotFoundException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problemDetail.setTitle("Event Not Found");
problemDetail.setType(URI.create("urn:problem-type:event-not-found"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Handles invalid timezone. */
@ExceptionHandler(InvalidTimezoneException.class)
public ResponseEntity<ProblemDetail> handleInvalidTimezone(
InvalidTimezoneException ex) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, ex.getMessage());
problemDetail.setTitle("Invalid Timezone");
problemDetail.setType(URI.create("urn:problem-type:invalid-timezone"));
return ResponseEntity.badRequest()
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
}
/** Catches all unhandled exceptions. */ /** Catches all unhandled exceptions. */
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAll(Exception ex) { public ResponseEntity<ProblemDetail> handleAll(Exception ex) {

View File

@@ -34,6 +34,9 @@ public class EventJpaEntity {
@Column(name = "date_time", nullable = false) @Column(name = "date_time", nullable = false)
private OffsetDateTime dateTime; private OffsetDateTime dateTime;
@Column(nullable = false, length = 64)
private String timezone;
@Column(length = 500) @Column(length = 500)
private String location; private String location;
@@ -103,6 +106,16 @@ public class EventJpaEntity {
this.dateTime = dateTime; this.dateTime = dateTime;
} }
/** Returns the IANA timezone name. */
public String getTimezone() {
return timezone;
}
/** Sets the IANA timezone name. */
public void setTimezone(String timezone) {
this.timezone = timezone;
}
/** Returns the event location. */ /** Returns the event location. */
public String getLocation() { public String getLocation() {
return location; return location;

View File

@@ -2,6 +2,7 @@ package de.fete.adapter.out.persistence;
import de.fete.domain.model.Event; import de.fete.domain.model.Event;
import de.fete.domain.port.out.EventRepository; import de.fete.domain.port.out.EventRepository;
import java.time.ZoneId;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -37,6 +38,7 @@ public class EventPersistenceAdapter implements EventRepository {
entity.setTitle(event.getTitle()); entity.setTitle(event.getTitle());
entity.setDescription(event.getDescription()); entity.setDescription(event.getDescription());
entity.setDateTime(event.getDateTime()); entity.setDateTime(event.getDateTime());
entity.setTimezone(event.getTimezone().getId());
entity.setLocation(event.getLocation()); entity.setLocation(event.getLocation());
entity.setExpiryDate(event.getExpiryDate()); entity.setExpiryDate(event.getExpiryDate());
entity.setCreatedAt(event.getCreatedAt()); entity.setCreatedAt(event.getCreatedAt());
@@ -51,6 +53,7 @@ public class EventPersistenceAdapter implements EventRepository {
event.setTitle(entity.getTitle()); event.setTitle(entity.getTitle());
event.setDescription(entity.getDescription()); event.setDescription(entity.getDescription());
event.setDateTime(entity.getDateTime()); event.setDateTime(entity.getDateTime());
event.setTimezone(ZoneId.of(entity.getTimezone()));
event.setLocation(entity.getLocation()); event.setLocation(entity.getLocation());
event.setExpiryDate(entity.getExpiryDate()); event.setExpiryDate(entity.getExpiryDate());
event.setCreatedAt(entity.getCreatedAt()); event.setCreatedAt(entity.getCreatedAt());

View File

@@ -0,0 +1,12 @@
package de.fete.application.service;
import java.util.UUID;
/** Thrown when an event cannot be found by its token. */
public class EventNotFoundException extends RuntimeException {
/** Creates a new exception for the given event token. */
public EventNotFoundException(UUID eventToken) {
super("Event not found: " + eventToken);
}
}

View File

@@ -3,16 +3,18 @@ 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.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateEventUseCase;
import de.fete.domain.port.in.GetEventUseCase;
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.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** Application service implementing event creation. */ /** Application service implementing event creation and retrieval. */
@Service @Service
public class EventService implements CreateEventUseCase { public class EventService implements CreateEventUseCase, GetEventUseCase {
private final EventRepository eventRepository; private final EventRepository eventRepository;
private final Clock clock; private final Clock clock;
@@ -35,10 +37,16 @@ public class EventService implements CreateEventUseCase {
event.setTitle(command.title()); event.setTitle(command.title());
event.setDescription(command.description()); event.setDescription(command.description());
event.setDateTime(command.dateTime()); event.setDateTime(command.dateTime());
event.setTimezone(command.timezone());
event.setLocation(command.location()); event.setLocation(command.location());
event.setExpiryDate(command.expiryDate()); event.setExpiryDate(command.expiryDate());
event.setCreatedAt(OffsetDateTime.now(clock)); event.setCreatedAt(OffsetDateTime.now(clock));
return eventRepository.save(event); return eventRepository.save(event);
} }
@Override
public Optional<Event> getByEventToken(UUID eventToken) {
return eventRepository.findByEventToken(eventToken);
}
} }

View File

@@ -0,0 +1,10 @@
package de.fete.application.service;
/** Thrown when an invalid IANA timezone ID is provided. */
public class InvalidTimezoneException extends RuntimeException {
/** Creates a new exception for the given invalid timezone. */
public InvalidTimezoneException(String timezone) {
super("Invalid IANA timezone: " + timezone);
}
}

View File

@@ -2,12 +2,14 @@ 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;
/** Command carrying the data needed to create an event. */ /** Command carrying the data needed to create an event. */
public record CreateEventCommand( public record CreateEventCommand(
String title, String title,
String description, String description,
OffsetDateTime dateTime, OffsetDateTime dateTime,
ZoneId timezone,
String location, String location,
LocalDate expiryDate LocalDate expiryDate
) {} ) {}

View File

@@ -2,6 +2,7 @@ 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.util.UUID; import java.util.UUID;
/** Domain entity representing an event. */ /** Domain entity representing an event. */
@@ -13,6 +14,7 @@ public class Event {
private String title; private String title;
private String description; private String description;
private OffsetDateTime dateTime; private OffsetDateTime dateTime;
private ZoneId timezone;
private String location; private String location;
private LocalDate expiryDate; private LocalDate expiryDate;
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@@ -77,6 +79,16 @@ public class Event {
this.dateTime = dateTime; this.dateTime = dateTime;
} }
/** Returns the IANA timezone. */
public ZoneId getTimezone() {
return timezone;
}
/** Sets the IANA timezone. */
public void setTimezone(ZoneId timezone) {
this.timezone = timezone;
}
/** Returns the event location. */ /** Returns the event location. */
public String getLocation() { public String getLocation() {
return location; return location;

View File

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

View File

@@ -1,12 +1,22 @@
package de.fete.adapter.in.web; package de.fete.adapter.in.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
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.CreateEventResponse;
import de.fete.adapter.out.persistence.EventJpaEntity;
import de.fete.adapter.out.persistence.EventJpaRepository;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
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.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
@@ -23,63 +33,89 @@ class EventControllerIntegrationTest {
@Autowired @Autowired
private MockMvc mockMvc; private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private EventJpaRepository jpaRepository;
// --- Create Event tests ---
@Test @Test
void createEventWithValidBody() throws Exception { void createEventWithValidBody() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("Birthday Party")
{ .description("Come celebrate!")
"title": "Birthday Party", .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"description": "Come celebrate!", .timezone("Europe/Berlin")
"dateTime": "2026-06-15T20:00:00+02:00", .location("Berlin")
"location": "Berlin", .expiryDate(LocalDate.now().plusDays(30));
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.eventToken").isNotEmpty()) .andExpect(jsonPath("$.eventToken").isNotEmpty())
.andExpect(jsonPath("$.organizerToken").isNotEmpty()) .andExpect(jsonPath("$.organizerToken").isNotEmpty())
.andExpect(jsonPath("$.title").value("Birthday Party")) .andExpect(jsonPath("$.title").value("Birthday Party"))
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.dateTime").isNotEmpty()) .andExpect(jsonPath("$.dateTime").isNotEmpty())
.andExpect(jsonPath("$.expiryDate").isNotEmpty()); .andExpect(jsonPath("$.expiryDate").isNotEmpty())
.andReturn();
var response = objectMapper.readValue(
result.getResponse().getContentAsString(), CreateEventResponse.class);
EventJpaEntity persisted = jpaRepository
.findByEventToken(response.getEventToken()).orElseThrow();
assertThat(persisted.getTitle()).isEqualTo("Birthday Party");
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
assertThat(persisted.getLocation()).isEqualTo("Berlin");
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
assertThat(persisted.getDateTime().toInstant())
.isEqualTo(request.getDateTime().toInstant());
assertThat(persisted.getOrganizerToken()).isNotNull();
assertThat(persisted.getCreatedAt()).isNotNull();
} }
@Test @Test
void createEventWithOptionalFieldsNull() throws Exception { void createEventWithOptionalFieldsNull() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("Minimal Event")
{ .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"title": "Minimal Event", .timezone("UTC")
"dateTime": "2026-06-15T20:00:00+02:00", .expiryDate(LocalDate.now().plusDays(30));
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") var result = mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated()) .andExpect(status().isCreated())
.andExpect(jsonPath("$.eventToken").isNotEmpty()) .andExpect(jsonPath("$.eventToken").isNotEmpty())
.andExpect(jsonPath("$.organizerToken").isNotEmpty()) .andExpect(jsonPath("$.organizerToken").isNotEmpty())
.andExpect(jsonPath("$.title").value("Minimal Event")); .andExpect(jsonPath("$.title").value("Minimal Event"))
.andReturn();
var response = objectMapper.readValue(
result.getResponse().getContentAsString(), CreateEventResponse.class);
EventJpaEntity persisted = jpaRepository
.findByEventToken(response.getEventToken()).orElseThrow();
assertThat(persisted.getTitle()).isEqualTo("Minimal Event");
assertThat(persisted.getDescription()).isNull();
assertThat(persisted.getLocation()).isNull();
} }
@Test @Test
void createEventMissingTitleReturns400() throws Exception { void createEventMissingTitleReturns400() throws Exception {
String body = var request = new CreateEventRequest()
""" .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
{ .timezone("Europe/Berlin")
"dateTime": "2026-06-15T20:00:00+02:00", .expiryDate(LocalDate.now().plusDays(30));
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").value("Validation Failed")) .andExpect(jsonPath("$.title").value("Validation Failed"))
@@ -88,17 +124,14 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventMissingDateTimeReturns400() throws Exception { void createEventMissingDateTimeReturns400() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("No Date")
{ .timezone("Europe/Berlin")
"title": "No Date", .expiryDate(LocalDate.now().plusDays(30));
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray()); .andExpect(jsonPath("$.fieldErrors").isArray());
@@ -106,17 +139,14 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventMissingExpiryDateReturns400() throws Exception { void createEventMissingExpiryDateReturns400() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("No Expiry")
{ .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"title": "No Expiry", .timezone("Europe/Berlin");
"dateTime": "2026-06-15T20:00:00+02:00"
}
""";
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.fieldErrors").isArray()); .andExpect(jsonPath("$.fieldErrors").isArray());
@@ -124,18 +154,15 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventExpiryDateInPastReturns400() throws Exception { void createEventExpiryDateInPastReturns400() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("Past Expiry")
{ .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"title": "Past Expiry", .timezone("Europe/Berlin")
"dateTime": "2026-06-15T20:00:00+02:00", .expiryDate(LocalDate.of(2025, 1, 1));
"expiryDate": "2025-01-01"
}
""";
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
@@ -143,18 +170,15 @@ class EventControllerIntegrationTest {
@Test @Test
void createEventExpiryDateTodayReturns400() throws Exception { void createEventExpiryDateTodayReturns400() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("Today Expiry")
{ .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"title": "Today Expiry", .timezone("Europe/Berlin")
"dateTime": "2026-06-15T20:00:00+02:00", .expiryDate(LocalDate.now());
"expiryDate": "%s"
}
""".formatted(LocalDate.now());
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
@@ -162,19 +186,101 @@ class EventControllerIntegrationTest {
@Test @Test
void errorResponseContentTypeIsProblemJson() throws Exception { void errorResponseContentTypeIsProblemJson() throws Exception {
String body = var request = new CreateEventRequest()
""" .title("")
{ .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
"title": "", .timezone("Europe/Berlin")
"dateTime": "2026-06-15T20:00:00+02:00", .expiryDate(LocalDate.now().plusDays(30));
"expiryDate": "%s"
}
""".formatted(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events") mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json")); .andExpect(content().contentTypeCompatibleWith("application/problem+json"));
} }
@Test
void createEventWithInvalidTimezoneReturns400() throws Exception {
var request = new CreateEventRequest()
.title("Bad TZ")
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
.timezone("Not/A/Zone")
.expiryDate(LocalDate.now().plusDays(30));
mockMvc.perform(post("/api/events")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
}
// --- GET /events/{token} tests ---
@Test
void getEventReturnsFullResponse() throws Exception {
EventJpaEntity entity = seedEvent(
"Summer BBQ", "Bring drinks!", "Europe/Berlin",
"Central Park", LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.eventToken").value(entity.getEventToken().toString()))
.andExpect(jsonPath("$.title").value("Summer BBQ"))
.andExpect(jsonPath("$.description").value("Bring drinks!"))
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
.andExpect(jsonPath("$.location").value("Central Park"))
.andExpect(jsonPath("$.attendeeCount").value(0))
.andExpect(jsonPath("$.expired").value(false))
.andExpect(jsonPath("$.dateTime").isNotEmpty());
}
@Test
void getEventWithOptionalFieldsAbsent() throws Exception {
EventJpaEntity entity = seedEvent(
"Minimal", null, "UTC", null, LocalDate.now().plusDays(30));
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Minimal"))
.andExpect(jsonPath("$.description").doesNotExist())
.andExpect(jsonPath("$.location").doesNotExist())
.andExpect(jsonPath("$.attendeeCount").value(0));
}
@Test
void getEventNotFoundReturns404() throws Exception {
mockMvc.perform(get("/api/events/" + UUID.randomUUID()))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
}
@Test
void getExpiredEventReturnsExpiredTrue() throws Exception {
EventJpaEntity entity = seedEvent(
"Past Event", "It happened", "Europe/Berlin",
"Old Venue", LocalDate.now().minusDays(1));
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Past Event"))
.andExpect(jsonPath("$.expired").value(true));
}
private EventJpaEntity seedEvent(
String title, String description, String timezone,
String location, LocalDate expiryDate) {
var entity = new EventJpaEntity();
entity.setEventToken(UUID.randomUUID());
entity.setOrganizerToken(UUID.randomUUID());
entity.setTitle(title);
entity.setDescription(description);
entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)));
entity.setTimezone(timezone);
entity.setLocation(location);
entity.setExpiryDate(expiryDate);
entity.setCreatedAt(OffsetDateTime.now());
return jpaRepository.save(entity);
}
} }

View File

@@ -7,6 +7,7 @@ import de.fete.domain.model.Event;
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.ZoneOffset; import java.time.ZoneOffset;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -65,6 +66,7 @@ class EventPersistenceAdapterTest {
event.setTitle("Full Event"); event.setTitle("Full Event");
event.setDescription("A detailed description"); event.setDescription("A detailed description");
event.setDateTime(dateTime); event.setDateTime(dateTime);
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Berlin, Germany"); event.setLocation("Berlin, Germany");
event.setExpiryDate(expiryDate); event.setExpiryDate(expiryDate);
event.setCreatedAt(createdAt); event.setCreatedAt(createdAt);
@@ -77,6 +79,7 @@ class EventPersistenceAdapterTest {
assertThat(found.getTitle()).isEqualTo("Full Event"); assertThat(found.getTitle()).isEqualTo("Full Event");
assertThat(found.getDescription()).isEqualTo("A detailed description"); assertThat(found.getDescription()).isEqualTo("A detailed description");
assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant()); assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant());
assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(found.getLocation()).isEqualTo("Berlin, Germany"); assertThat(found.getLocation()).isEqualTo("Berlin, Germany");
assertThat(found.getExpiryDate()).isEqualTo(expiryDate); assertThat(found.getExpiryDate()).isEqualTo(expiryDate);
assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant()); assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant());
@@ -89,6 +92,7 @@ class EventPersistenceAdapterTest {
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));
event.setTimezone(ZoneId.of("Europe/Berlin"));
event.setLocation("Somewhere"); event.setLocation("Somewhere");
event.setExpiryDate(LocalDate.now().plusDays(30)); event.setExpiryDate(LocalDate.now().plusDays(30));
event.setCreatedAt(OffsetDateTime.now()); event.setCreatedAt(OffsetDateTime.now());

View File

@@ -16,6 +16,8 @@ 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.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;
@@ -50,6 +52,7 @@ class EventServiceTest {
"Birthday Party", "Birthday Party",
"Come celebrate!", "Come celebrate!",
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
ZoneId.of("Europe/Berlin"),
"Berlin", "Berlin",
LocalDate.of(2026, 7, 15) LocalDate.of(2026, 7, 15)
); );
@@ -58,28 +61,13 @@ class EventServiceTest {
assertThat(result.getTitle()).isEqualTo("Birthday Party"); assertThat(result.getTitle()).isEqualTo("Birthday Party");
assertThat(result.getDescription()).isEqualTo("Come celebrate!"); assertThat(result.getDescription()).isEqualTo("Come celebrate!");
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
assertThat(result.getLocation()).isEqualTo("Berlin"); assertThat(result.getLocation()).isEqualTo("Berlin");
assertThat(result.getEventToken()).isNotNull(); assertThat(result.getEventToken()).isNotNull();
assertThat(result.getOrganizerToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull();
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK)); assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
} }
@Test
void eventTokenAndOrganizerTokenAreDifferent() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null,
LocalDate.now(FIXED_CLOCK).plusDays(30)
);
Event result = eventService.createEvent(command);
assertThat(result.getEventToken()).isNotEqualTo(result.getOrganizerToken());
}
@Test @Test
void repositorySaveCalledExactlyOnce() { void repositorySaveCalledExactlyOnce() {
when(eventRepository.save(any(Event.class))) when(eventRepository.save(any(Event.class)))
@@ -87,7 +75,7 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK).plusDays(30) LocalDate.now(FIXED_CLOCK).plusDays(30)
); );
@@ -102,7 +90,7 @@ class EventServiceTest {
void expiryDateTodayThrowsException() { void expiryDateTodayThrowsException() {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK) LocalDate.now(FIXED_CLOCK)
); );
@@ -114,7 +102,7 @@ class EventServiceTest {
void expiryDateInPastThrowsException() { void expiryDateInPastThrowsException() {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK).minusDays(5) LocalDate.now(FIXED_CLOCK).minusDays(5)
); );
@@ -129,7 +117,7 @@ class EventServiceTest {
var command = new CreateEventCommand( var command = new CreateEventCommand(
"Test", null, "Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
LocalDate.now(FIXED_CLOCK).plusDays(1) LocalDate.now(FIXED_CLOCK).plusDays(1)
); );
@@ -137,4 +125,51 @@ class EventServiceTest {
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6)); assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
} }
// --- GetEventUseCase tests (T004) ---
@Test
void getByEventTokenReturnsEvent() {
UUID token = UUID.randomUUID();
var event = new Event();
event.setEventToken(token);
event.setTitle("Found Event");
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.of(event));
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isPresent();
assertThat(result.get().getTitle()).isEqualTo("Found Event");
}
@Test
void getByEventTokenReturnsEmptyForUnknownToken() {
UUID token = UUID.randomUUID();
when(eventRepository.findByEventToken(token))
.thenReturn(Optional.empty());
Optional<Event> result = eventService.getByEventToken(token);
assertThat(result).isEmpty();
}
// --- Timezone validation tests (T006) ---
@Test
void createEventWithValidTimezoneSucceeds() {
when(eventRepository.save(any(Event.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
var command = new CreateEventCommand(
"Test", null,
OffsetDateTime.now(FIXED_CLOCK).plusDays(1),
ZoneId.of("America/New_York"), null,
LocalDate.now(FIXED_CLOCK).plusDays(30)
);
Event result = eventService.createEvent(command);
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York"));
}
} }