diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java index db4a463..a0fc363 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/EventController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -3,9 +3,18 @@ package de.fete.adapter.in.web; import de.fete.adapter.in.web.api.EventsApi; import de.fete.adapter.in.web.model.CreateEventRequest; 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.Event; 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.ResponseEntity; import org.springframework.web.bind.annotation.RestController; @@ -15,19 +24,29 @@ import org.springframework.web.bind.annotation.RestController; public class EventController implements EventsApi { private final CreateEventUseCase createEventUseCase; + private final GetEventUseCase getEventUseCase; + private final Clock clock; - /** Creates a new controller with the given use case. */ - public EventController(CreateEventUseCase createEventUseCase) { + /** Creates a new controller with the given use cases and clock. */ + public EventController( + CreateEventUseCase createEventUseCase, + GetEventUseCase getEventUseCase, + Clock clock) { this.createEventUseCase = createEventUseCase; + this.getEventUseCase = getEventUseCase; + this.clock = clock; } @Override public ResponseEntity createEvent( CreateEventRequest request) { + ZoneId zoneId = parseTimezone(request.getTimezone()); + var command = new CreateEventCommand( request.getTitle(), request.getDescription(), request.getDateTime(), + zoneId, request.getLocation(), request.getExpiryDate() ); @@ -39,8 +58,36 @@ public class EventController implements EventsApi { response.setOrganizerToken(event.getOrganizerToken()); response.setTitle(event.getTitle()); response.setDateTime(event.getDateTime()); + response.setTimezone(event.getTimezone().getId()); response.setExpiryDate(event.getExpiryDate()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } + + @Override + public ResponseEntity 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); + } + } } 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 4ac221a..34c9726 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 @@ -1,6 +1,8 @@ package de.fete.adapter.in.web; +import de.fete.application.service.EventNotFoundException; import de.fete.application.service.ExpiryDateInPastException; +import de.fete.application.service.InvalidTimezoneException; import java.net.URI; import java.util.List; import java.util.Map; @@ -57,6 +59,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { .body(problemDetail); } + /** Handles event not found. */ + @ExceptionHandler(EventNotFoundException.class) + public ResponseEntity 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 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. */ @ExceptionHandler(Exception.class) public ResponseEntity handleAll(Exception ex) { diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java index d644503..04a33b0 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java @@ -34,6 +34,9 @@ public class EventJpaEntity { @Column(name = "date_time", nullable = false) private OffsetDateTime dateTime; + @Column(nullable = false, length = 64) + private String timezone; + @Column(length = 500) private String location; @@ -103,6 +106,16 @@ public class EventJpaEntity { 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. */ public String getLocation() { return location; diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java index 360e099..c4e78c2 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java @@ -2,6 +2,7 @@ package de.fete.adapter.out.persistence; import de.fete.domain.model.Event; import de.fete.domain.port.out.EventRepository; +import java.time.ZoneId; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Repository; @@ -37,6 +38,7 @@ public class EventPersistenceAdapter implements EventRepository { entity.setTitle(event.getTitle()); entity.setDescription(event.getDescription()); entity.setDateTime(event.getDateTime()); + entity.setTimezone(event.getTimezone().getId()); entity.setLocation(event.getLocation()); entity.setExpiryDate(event.getExpiryDate()); entity.setCreatedAt(event.getCreatedAt()); @@ -51,6 +53,7 @@ public class EventPersistenceAdapter implements EventRepository { event.setTitle(entity.getTitle()); event.setDescription(entity.getDescription()); event.setDateTime(entity.getDateTime()); + event.setTimezone(ZoneId.of(entity.getTimezone())); event.setLocation(entity.getLocation()); event.setExpiryDate(entity.getExpiryDate()); event.setCreatedAt(entity.getCreatedAt()); diff --git a/backend/src/main/java/de/fete/application/service/EventNotFoundException.java b/backend/src/main/java/de/fete/application/service/EventNotFoundException.java new file mode 100644 index 0000000..6e3025f --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventNotFoundException.java @@ -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); + } +} 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 fd199d2..315c5a1 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -3,16 +3,18 @@ package de.fete.application.service; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.port.in.CreateEventUseCase; +import de.fete.domain.port.in.GetEventUseCase; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Service; -/** Application service implementing event creation. */ +/** Application service implementing event creation and retrieval. */ @Service -public class EventService implements CreateEventUseCase { +public class EventService implements CreateEventUseCase, GetEventUseCase { private final EventRepository eventRepository; private final Clock clock; @@ -35,10 +37,16 @@ public class EventService implements CreateEventUseCase { event.setTitle(command.title()); event.setDescription(command.description()); event.setDateTime(command.dateTime()); + event.setTimezone(command.timezone()); event.setLocation(command.location()); event.setExpiryDate(command.expiryDate()); event.setCreatedAt(OffsetDateTime.now(clock)); return eventRepository.save(event); } + + @Override + public Optional getByEventToken(UUID eventToken) { + return eventRepository.findByEventToken(eventToken); + } } diff --git a/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java b/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java new file mode 100644 index 0000000..4269804 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java @@ -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); + } +} diff --git a/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java index f32ac08..331df0c 100644 --- a/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java +++ b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java @@ -2,12 +2,14 @@ package de.fete.domain.model; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; /** Command carrying the data needed to create an event. */ public record CreateEventCommand( String title, String description, OffsetDateTime dateTime, + ZoneId timezone, String location, LocalDate expiryDate ) {} diff --git a/backend/src/main/java/de/fete/domain/model/Event.java b/backend/src/main/java/de/fete/domain/model/Event.java index cb602c8..1575137 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -2,6 +2,7 @@ package de.fete.domain.model; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.UUID; /** Domain entity representing an event. */ @@ -13,6 +14,7 @@ public class Event { private String title; private String description; private OffsetDateTime dateTime; + private ZoneId timezone; private String location; private LocalDate expiryDate; private OffsetDateTime createdAt; @@ -77,6 +79,16 @@ public class Event { 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. */ public String getLocation() { return location; diff --git a/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java new file mode 100644 index 0000000..1731e92 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/GetEventUseCase.java @@ -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 getByEventToken(UUID eventToken); +} 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 6bbdfa7..f47f433 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 @@ -1,12 +1,22 @@ 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.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; import de.fete.TestcontainersConfig; +import de.fete.adapter.in.web.model.CreateEventRequest; +import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.adapter.out.persistence.EventJpaEntity; +import de.fete.adapter.out.persistence.EventJpaRepository; import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -23,63 +33,89 @@ class EventControllerIntegrationTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EventJpaRepository jpaRepository; + + // --- Create Event tests --- + @Test void createEventWithValidBody() throws Exception { - String body = - """ - { - "title": "Birthday Party", - "description": "Come celebrate!", - "dateTime": "2026-06-15T20:00:00+02:00", - "location": "Berlin", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("Birthday Party") + .description("Come celebrate!") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .location("Berlin") + .expiryDate(LocalDate.now().plusDays(30)); - mockMvc.perform(post("/api/events") + var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.eventToken").isNotEmpty()) .andExpect(jsonPath("$.organizerToken").isNotEmpty()) .andExpect(jsonPath("$.title").value("Birthday Party")) + .andExpect(jsonPath("$.timezone").value("Europe/Berlin")) .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 void createEventWithOptionalFieldsNull() throws Exception { - String body = - """ - { - "title": "Minimal Event", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("Minimal Event") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("UTC") + .expiryDate(LocalDate.now().plusDays(30)); - mockMvc.perform(post("/api/events") + var result = mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.eventToken").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 void createEventMissingTitleReturns400() throws Exception { - String body = - """ - { - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + 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)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.title").value("Validation Failed")) @@ -88,17 +124,14 @@ class EventControllerIntegrationTest { @Test void createEventMissingDateTimeReturns400() throws Exception { - String body = - """ - { - "title": "No Date", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("No Date") + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now().plusDays(30)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); @@ -106,17 +139,14 @@ class EventControllerIntegrationTest { @Test void createEventMissingExpiryDateReturns400() throws Exception { - String body = - """ - { - "title": "No Expiry", - "dateTime": "2026-06-15T20:00:00+02:00" - } - """; + var request = new CreateEventRequest() + .title("No Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin"); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.fieldErrors").isArray()); @@ -124,18 +154,15 @@ class EventControllerIntegrationTest { @Test void createEventExpiryDateInPastReturns400() throws Exception { - String body = - """ - { - "title": "Past Expiry", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "2025-01-01" - } - """; + var request = new CreateEventRequest() + .title("Past Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.of(2025, 1, 1)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); @@ -143,18 +170,15 @@ class EventControllerIntegrationTest { @Test void createEventExpiryDateTodayReturns400() throws Exception { - String body = - """ - { - "title": "Today Expiry", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now()); + var request = new CreateEventRequest() + .title("Today Expiry") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now()); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(content().contentTypeCompatibleWith("application/problem+json")) .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); @@ -162,19 +186,101 @@ class EventControllerIntegrationTest { @Test void errorResponseContentTypeIsProblemJson() throws Exception { - String body = - """ - { - "title": "", - "dateTime": "2026-06-15T20:00:00+02:00", - "expiryDate": "%s" - } - """.formatted(LocalDate.now().plusDays(30)); + var request = new CreateEventRequest() + .title("") + .dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))) + .timezone("Europe/Berlin") + .expiryDate(LocalDate.now().plusDays(30)); mockMvc.perform(post("/api/events") .contentType(MediaType.APPLICATION_JSON) - .content(body)) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .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); + } } diff --git a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java index c743872..eedac71 100644 --- a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java @@ -7,6 +7,7 @@ import de.fete.domain.model.Event; import de.fete.domain.port.out.EventRepository; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.Optional; import java.util.UUID; @@ -65,6 +66,7 @@ class EventPersistenceAdapterTest { event.setTitle("Full Event"); event.setDescription("A detailed description"); event.setDateTime(dateTime); + event.setTimezone(ZoneId.of("Europe/Berlin")); event.setLocation("Berlin, Germany"); event.setExpiryDate(expiryDate); event.setCreatedAt(createdAt); @@ -77,6 +79,7 @@ class EventPersistenceAdapterTest { assertThat(found.getTitle()).isEqualTo("Full Event"); assertThat(found.getDescription()).isEqualTo("A detailed description"); assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant()); + assertThat(found.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(found.getLocation()).isEqualTo("Berlin, Germany"); assertThat(found.getExpiryDate()).isEqualTo(expiryDate); assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant()); @@ -89,6 +92,7 @@ class EventPersistenceAdapterTest { event.setTitle("Test Event"); event.setDescription("Test description"); event.setDateTime(OffsetDateTime.now().plusDays(7)); + event.setTimezone(ZoneId.of("Europe/Berlin")); event.setLocation("Somewhere"); event.setExpiryDate(LocalDate.now().plusDays(30)); event.setCreatedAt(OffsetDateTime.now()); 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 436b579..7062536 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -16,6 +16,8 @@ import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -50,6 +52,7 @@ class EventServiceTest { "Birthday Party", "Come celebrate!", OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), + ZoneId.of("Europe/Berlin"), "Berlin", LocalDate.of(2026, 7, 15) ); @@ -58,28 +61,13 @@ class EventServiceTest { assertThat(result.getTitle()).isEqualTo("Birthday Party"); assertThat(result.getDescription()).isEqualTo("Come celebrate!"); + assertThat(result.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); assertThat(result.getLocation()).isEqualTo("Berlin"); assertThat(result.getEventToken()).isNotNull(); assertThat(result.getOrganizerToken()).isNotNull(); 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 void repositorySaveCalledExactlyOnce() { when(eventRepository.save(any(Event.class))) @@ -87,7 +75,7 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).plusDays(30) ); @@ -102,7 +90,7 @@ class EventServiceTest { void expiryDateTodayThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK) ); @@ -114,7 +102,7 @@ class EventServiceTest { void expiryDateInPastThrowsException() { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).minusDays(5) ); @@ -129,7 +117,7 @@ class EventServiceTest { var command = new CreateEventCommand( "Test", null, - OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), ZONE, null, LocalDate.now(FIXED_CLOCK).plusDays(1) ); @@ -137,4 +125,51 @@ class EventServiceTest { 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 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 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")); + } }