Validate expiryDate is strictly after eventDate and harden rejection tests
Adds ExpiryDateBeforeEventException (400) when expiryDate <= eventDate, asserts DB row count unchanged after every rejection in integration tests, and replaces all hardcoded dates in EventServiceTest with TODAY-relative expressions derived from the fixed Clock. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package de.fete.adapter.in.web;
|
||||
|
||||
import de.fete.application.service.EventExpiredException;
|
||||
import de.fete.application.service.EventNotFoundException;
|
||||
import de.fete.application.service.ExpiryDateBeforeEventException;
|
||||
import de.fete.application.service.ExpiryDateInPastException;
|
||||
import de.fete.application.service.InvalidTimezoneException;
|
||||
import java.net.URI;
|
||||
@@ -47,6 +48,19 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
|
||||
return handleExceptionInternal(ex, problemDetail, headers, status, request);
|
||||
}
|
||||
|
||||
/** Handles expiry date before event date. */
|
||||
@ExceptionHandler(ExpiryDateBeforeEventException.class)
|
||||
public ResponseEntity<ProblemDetail> handleExpiryDateBeforeEvent(
|
||||
ExpiryDateBeforeEventException ex) {
|
||||
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||
problemDetail.setTitle("Invalid Expiry Date");
|
||||
problemDetail.setType(URI.create("urn:problem-type:expiry-date-before-event"));
|
||||
return ResponseEntity.badRequest()
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.body(problemDetail);
|
||||
}
|
||||
|
||||
/** Handles expiry date validation failures. */
|
||||
@ExceptionHandler(ExpiryDateInPastException.class)
|
||||
public ResponseEntity<ProblemDetail> handleExpiryDateInPast(
|
||||
|
||||
@@ -32,6 +32,10 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||
throw new ExpiryDateInPastException(command.expiryDate());
|
||||
}
|
||||
|
||||
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
||||
}
|
||||
|
||||
var event = new Event();
|
||||
event.setEventToken(EventToken.generate());
|
||||
event.setOrganizerToken(OrganizerToken.generate());
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.fete.application.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
/** Thrown when an event's expiry date is not after the event date. */
|
||||
public class ExpiryDateBeforeEventException extends RuntimeException {
|
||||
|
||||
/** Creates a new exception for the given dates. */
|
||||
public ExpiryDateBeforeEventException(LocalDate expiryDate, OffsetDateTime dateTime) {
|
||||
super("Expiry date " + expiryDate + " must be after event date " + dateTime.toLocalDate());
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class EventControllerIntegrationTest {
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.location("Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -92,7 +92,7 @@ class EventControllerIntegrationTest {
|
||||
.title("Minimal Event")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("UTC")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
var result = mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -115,10 +115,12 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventMissingTitleReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -127,14 +129,18 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.title").value("Validation Failed"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingDateTimeReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Date")
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -142,10 +148,14 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("No Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
@@ -157,10 +167,14 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Past Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
@@ -173,10 +187,14 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Today Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
@@ -189,6 +207,48 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Bad Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 10));
|
||||
|
||||
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:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
var request = new CreateEventRequest()
|
||||
.title("Same Day Expiry")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.of(2026, 6, 15));
|
||||
|
||||
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:expiry-date-before-event"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -197,7 +257,7 @@ class EventControllerIntegrationTest {
|
||||
.title("")
|
||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||
.timezone("Europe/Berlin")
|
||||
.expiryDate(LocalDate.now().plusDays(30));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -208,11 +268,13 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createEventWithInvalidTimezoneReturns400() throws Exception {
|
||||
long countBefore = jpaRepository.count();
|
||||
|
||||
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));
|
||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
||||
|
||||
mockMvc.perform(post("/api/events")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -220,6 +282,8 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:invalid-timezone"));
|
||||
|
||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
// --- GET /events/{token} tests ---
|
||||
@@ -307,6 +371,7 @@ class EventControllerIntegrationTest {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"RSVP Event", null, "Europe/Berlin",
|
||||
null, LocalDate.now().plusDays(30));
|
||||
long countBefore = rsvpJpaRepository.count();
|
||||
|
||||
var request = new CreateRsvpRequest().name("");
|
||||
|
||||
@@ -315,6 +380,8 @@ class EventControllerIntegrationTest {
|
||||
.content(objectMapper.writeValueAsString(request)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"));
|
||||
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -339,6 +406,8 @@ class EventControllerIntegrationTest {
|
||||
|
||||
@Test
|
||||
void createRsvpForUnknownEventReturns404() throws Exception {
|
||||
long countBefore = rsvpJpaRepository.count();
|
||||
|
||||
var request = new CreateRsvpRequest().name("Ghost");
|
||||
|
||||
mockMvc.perform(post("/api/events/" + UUID.randomUUID() + "/rsvps")
|
||||
@@ -347,6 +416,8 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -354,6 +425,7 @@ class EventControllerIntegrationTest {
|
||||
EventJpaEntity event = seedEvent(
|
||||
"Expired Party", null, "Europe/Berlin",
|
||||
null, LocalDate.now().minusDays(1));
|
||||
long countBefore = rsvpJpaRepository.count();
|
||||
|
||||
var request = new CreateRsvpRequest().name("Late Guest");
|
||||
|
||||
@@ -363,6 +435,8 @@ class EventControllerIntegrationTest {
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-expired"));
|
||||
|
||||
assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore);
|
||||
}
|
||||
|
||||
private EventJpaEntity seedEvent(
|
||||
|
||||
@@ -16,7 +16,6 @@ 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;
|
||||
@@ -32,6 +31,7 @@ class EventServiceTest {
|
||||
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);
|
||||
private static final LocalDate TODAY = LocalDate.ofInstant(FIXED_INSTANT, ZONE);
|
||||
|
||||
@Mock
|
||||
private EventRepository eventRepository;
|
||||
@@ -51,21 +51,21 @@ class EventServiceTest {
|
||||
var command = new CreateEventCommand(
|
||||
"Birthday Party",
|
||||
"Come celebrate!",
|
||||
OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)),
|
||||
ZoneId.of("Europe/Berlin"),
|
||||
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE,
|
||||
"Berlin",
|
||||
LocalDate.of(2026, 7, 15)
|
||||
TODAY.plusDays(120)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getTitle()).isEqualTo("Birthday Party");
|
||||
assertThat(result.getDescription()).isEqualTo("Come celebrate!");
|
||||
assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin"));
|
||||
assertThat(result.getTimezone()).isEqualTo(ZONE);
|
||||
assertThat(result.getLocation()).isEqualTo("Berlin");
|
||||
assertThat(result.getEventToken()).isNotNull();
|
||||
assertThat(result.getOrganizerToken()).isNotNull();
|
||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.now(FIXED_CLOCK));
|
||||
assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -75,8 +75,8 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
);
|
||||
|
||||
eventService.createEvent(command);
|
||||
@@ -90,8 +90,8 @@ class EventServiceTest {
|
||||
void expiryDateTodayThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK)
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
@@ -102,8 +102,8 @@ class EventServiceTest {
|
||||
void expiryDateInPastThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).minusDays(5)
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.minusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
@@ -117,13 +117,56 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(1)
|
||||
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
||||
TODAY.plusDays(2)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6));
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateSameAsEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(10)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateBeforeEventDateThrowsException() {
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(5)
|
||||
);
|
||||
|
||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiryDateDayAfterEventDateSucceeds() {
|
||||
when(eventRepository.save(any(Event.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZONE, null,
|
||||
TODAY.plusDays(11)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
|
||||
}
|
||||
|
||||
// --- GetEventUseCase tests (T004) ---
|
||||
@@ -163,9 +206,9 @@ class EventServiceTest {
|
||||
|
||||
var command = new CreateEventCommand(
|
||||
"Test", null,
|
||||
OffsetDateTime.now(FIXED_CLOCK).plusDays(1),
|
||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||
ZoneId.of("America/New_York"), null,
|
||||
LocalDate.now(FIXED_CLOCK).plusDays(30)
|
||||
TODAY.plusDays(11)
|
||||
);
|
||||
|
||||
Event result = eventService.createEvent(command);
|
||||
|
||||
Reference in New Issue
Block a user