Implement event domain model and application service

Add Event entity, CreateEventCommand, ports (CreateEventUseCase,
EventRepository), and EventService with Clock injection for
deterministic testing. Expiry date must be in the future.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 10:56:28 +01:00
parent eeadaf58c7
commit 830ca55f20
9 changed files with 361 additions and 2 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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));

View File

@@ -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
) {}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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<Event> findByEventToken(UUID eventToken);
}

View File

@@ -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<Event> 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));
}
}

View File

@@ -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());
}
}