diff --git a/backend/src/main/java/de/fete/application/service/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java new file mode 100644 index 0000000..fd199d2 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -0,0 +1,44 @@ +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.out.EventRepository; +import java.time.Clock; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.springframework.stereotype.Service; + +/** Application service implementing event creation. */ +@Service +public class EventService implements CreateEventUseCase { + + private final EventRepository eventRepository; + private final Clock clock; + + /** Creates a new EventService with the given repository and clock. */ + public EventService(EventRepository eventRepository, Clock clock) { + this.eventRepository = eventRepository; + this.clock = clock; + } + + @Override + public Event createEvent(CreateEventCommand command) { + if (!command.expiryDate().isAfter(LocalDate.now(clock))) { + throw new ExpiryDateInPastException(command.expiryDate()); + } + + var event = new Event(); + event.setEventToken(UUID.randomUUID()); + event.setOrganizerToken(UUID.randomUUID()); + event.setTitle(command.title()); + event.setDescription(command.description()); + event.setDateTime(command.dateTime()); + event.setLocation(command.location()); + event.setExpiryDate(command.expiryDate()); + event.setCreatedAt(OffsetDateTime.now(clock)); + + return eventRepository.save(event); + } +} diff --git a/backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java b/backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java new file mode 100644 index 0000000..77808d4 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java @@ -0,0 +1,20 @@ +package de.fete.application.service; + +import java.time.LocalDate; + +/** Thrown when an event's expiry date is not in the future. */ +public class ExpiryDateInPastException extends RuntimeException { + + private final LocalDate expiryDate; + + /** Creates a new exception for the given invalid expiry date. */ + public ExpiryDateInPastException(LocalDate expiryDate) { + super("Expiry date must be in the future: " + expiryDate); + this.expiryDate = expiryDate; + } + + /** Returns the invalid expiry date. */ + public LocalDate getExpiryDate() { + return expiryDate; + } +} diff --git a/backend/src/main/java/de/fete/config/WebConfig.java b/backend/src/main/java/de/fete/config/WebConfig.java index e333e40..79c8ee9 100644 --- a/backend/src/main/java/de/fete/config/WebConfig.java +++ b/backend/src/main/java/de/fete/config/WebConfig.java @@ -1,6 +1,8 @@ package de.fete.config; import java.io.IOException; +import java.time.Clock; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; @@ -14,6 +16,11 @@ import org.springframework.web.servlet.resource.PathResourceResolver; @Configuration public class WebConfig implements WebMvcConfigurer { + @Bean + Clock clock() { + return Clock.systemDefaultZone(); + } + @Override public void configurePathMatch(PathMatchConfigurer configurer) { configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class)); diff --git a/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java new file mode 100644 index 0000000..f32ac08 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/CreateEventCommand.java @@ -0,0 +1,13 @@ +package de.fete.domain.model; + +import java.time.LocalDate; +import java.time.OffsetDateTime; + +/** Command carrying the data needed to create an event. */ +public record CreateEventCommand( + String title, + String description, + OffsetDateTime dateTime, + 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 new file mode 100644 index 0000000..cb602c8 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -0,0 +1,109 @@ +package de.fete.domain.model; + +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** Domain entity representing an event. */ +public class Event { + + private Long id; + private UUID eventToken; + private UUID organizerToken; + private String title; + private String description; + private OffsetDateTime dateTime; + private String location; + private LocalDate expiryDate; + private OffsetDateTime createdAt; + + /** Returns the internal database ID. */ + public Long getId() { + return id; + } + + /** Sets the internal database ID. */ + public void setId(Long id) { + this.id = id; + } + + /** Returns the public event token (UUID). */ + public UUID getEventToken() { + return eventToken; + } + + /** Sets the public event token. */ + public void setEventToken(UUID eventToken) { + this.eventToken = eventToken; + } + + /** Returns the secret organizer token (UUID). */ + public UUID getOrganizerToken() { + return organizerToken; + } + + /** Sets the secret organizer token. */ + public void setOrganizerToken(UUID organizerToken) { + this.organizerToken = organizerToken; + } + + /** Returns the event title. */ + public String getTitle() { + return title; + } + + /** Sets the event title. */ + public void setTitle(String title) { + this.title = title; + } + + /** Returns the event description. */ + public String getDescription() { + return description; + } + + /** Sets the event description. */ + public void setDescription(String description) { + this.description = description; + } + + /** Returns the event date and time with UTC offset. */ + public OffsetDateTime getDateTime() { + return dateTime; + } + + /** Sets the event date and time. */ + public void setDateTime(OffsetDateTime dateTime) { + this.dateTime = dateTime; + } + + /** Returns the event location. */ + public String getLocation() { + return location; + } + + /** Sets the event location. */ + public void setLocation(String location) { + this.location = location; + } + + /** Returns the expiry date after which event data is deleted. */ + public LocalDate getExpiryDate() { + return expiryDate; + } + + /** Sets the expiry date. */ + public void setExpiryDate(LocalDate expiryDate) { + this.expiryDate = expiryDate; + } + + /** Returns the creation timestamp. */ + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + /** Sets the creation timestamp. */ + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/de/fete/domain/port/in/CreateEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/CreateEventUseCase.java new file mode 100644 index 0000000..a0e6282 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/CreateEventUseCase.java @@ -0,0 +1,11 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.CreateEventCommand; +import de.fete.domain.model.Event; + +/** Inbound port for creating a new event. */ +public interface CreateEventUseCase { + + /** Creates an event from the given command and returns the persisted event. */ + Event createEvent(CreateEventCommand command); +} diff --git a/backend/src/main/java/de/fete/domain/port/out/EventRepository.java b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java new file mode 100644 index 0000000..62db149 --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/out/EventRepository.java @@ -0,0 +1,15 @@ +package de.fete.domain.port.out; + +import de.fete.domain.model.Event; +import java.util.Optional; +import java.util.UUID; + +/** Outbound port for persisting and retrieving events. */ +public interface EventRepository { + + /** Persists the given event and returns it with generated fields populated. */ + Event save(Event event); + + /** Finds an event by its public event token. */ + Optional findByEventToken(UUID eventToken); +} diff --git a/backend/src/test/java/de/fete/application/service/EventServiceTest.java b/backend/src/test/java/de/fete/application/service/EventServiceTest.java new file mode 100644 index 0000000..436b579 --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -0,0 +1,140 @@ +package de.fete.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.fete.domain.model.CreateEventCommand; +import de.fete.domain.model.Event; +import de.fete.domain.port.out.EventRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EventServiceTest { + + private static final ZoneId ZONE = ZoneId.of("Europe/Berlin"); + private static final Instant FIXED_INSTANT = + LocalDate.of(2026, 3, 5).atStartOfDay(ZONE).toInstant(); + private static final Clock FIXED_CLOCK = Clock.fixed(FIXED_INSTANT, ZONE); + + @Mock + private EventRepository eventRepository; + + private EventService eventService; + + @BeforeEach + void setUp() { + eventService = new EventService(eventRepository, FIXED_CLOCK); + } + + @Test + void createEventWithValidCommand() { + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var command = new CreateEventCommand( + "Birthday Party", + "Come celebrate!", + OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), + "Berlin", + LocalDate.of(2026, 7, 15) + ); + + Event result = eventService.createEvent(command); + + assertThat(result.getTitle()).isEqualTo("Birthday Party"); + assertThat(result.getDescription()).isEqualTo("Come celebrate!"); + 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))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + var command = new CreateEventCommand( + "Test", null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + LocalDate.now(FIXED_CLOCK).plusDays(30) + ); + + eventService.createEvent(command); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventRepository, times(1)).save(captor.capture()); + assertThat(captor.getValue().getTitle()).isEqualTo("Test"); + } + + @Test + void expiryDateTodayThrowsException() { + var command = new CreateEventCommand( + "Test", null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + LocalDate.now(FIXED_CLOCK) + ); + + assertThatThrownBy(() -> eventService.createEvent(command)) + .isInstanceOf(ExpiryDateInPastException.class); + } + + @Test + void expiryDateInPastThrowsException() { + var command = new CreateEventCommand( + "Test", null, + OffsetDateTime.now(FIXED_CLOCK).plusDays(1), null, + LocalDate.now(FIXED_CLOCK).minusDays(5) + ); + + assertThatThrownBy(() -> eventService.createEvent(command)) + .isInstanceOf(ExpiryDateInPastException.class); + } + + @Test + void expiryDateTomorrowSucceeds() { + 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(1) + ); + + Event result = eventService.createEvent(command); + + assertThat(result.getExpiryDate()).isEqualTo(LocalDate.of(2026, 3, 6)); + } +} diff --git a/backend/src/test/java/de/fete/config/WebConfigTest.java b/backend/src/test/java/de/fete/config/WebConfigTest.java index c23b60f..2170412 100644 --- a/backend/src/test/java/de/fete/config/WebConfigTest.java +++ b/backend/src/test/java/de/fete/config/WebConfigTest.java @@ -29,8 +29,8 @@ class WebConfigTest { @Test void apiPrefixNotAccessibleWithoutIt() throws Exception { - // /health without /api prefix should not resolve to the API endpoint - mockMvc.perform(get("/health")) + // /events without /api prefix should not resolve to the API endpoint + mockMvc.perform(get("/events")) .andExpect(status().isNotFound()); } }