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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package de.fete.config;
|
package de.fete.config;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
@@ -14,6 +16,11 @@ import org.springframework.web.servlet.resource.PathResourceResolver;
|
|||||||
@Configuration
|
@Configuration
|
||||||
public class WebConfig implements WebMvcConfigurer {
|
public class WebConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Clock clock() {
|
||||||
|
return Clock.systemDefaultZone();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configurePathMatch(PathMatchConfigurer configurer) {
|
public void configurePathMatch(PathMatchConfigurer configurer) {
|
||||||
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
configurer.addPathPrefix("/api", c -> c.isAnnotationPresent(RestController.class));
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal file
109
backend/src/main/java/de/fete/domain/model/Event.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,8 @@ class WebConfigTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
void apiPrefixNotAccessibleWithoutIt() throws Exception {
|
||||||
// /health without /api prefix should not resolve to the API endpoint
|
// /events without /api prefix should not resolve to the API endpoint
|
||||||
mockMvc.perform(get("/health"))
|
mockMvc.perform(get("/events"))
|
||||||
.andExpect(status().isNotFound());
|
.andExpect(status().isNotFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user