diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java index 33e3f24..76e5630 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -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 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 handleExpiryDateInPast( diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index 407b5d3..1f03fa3 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -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()); diff --git a/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java b/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java new file mode 100644 index 0000000..ccccef3 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java @@ -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()); + } +} diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java index ed1242a..c92e670 100644 --- a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -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( diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java index eee8920..c3c3055 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -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);