From c80074093c18e5a148f3635066c640d66df6846f Mon Sep 17 00:00:00 2001 From: nitrix Date: Thu, 5 Mar 2026 10:56:39 +0100 Subject: [PATCH] Add persistence layer with Liquibase migration JPA entity, repository, persistence adapter for events. Liquibase changelog creates the events table with BIGSERIAL ID and UUID tokens. Co-Authored-By: Claude Opus 4.6 --- .../out/persistence/EventJpaEntity.java | 135 ++++++++++++++++++ .../out/persistence/EventJpaRepository.java | 12 ++ .../persistence/EventPersistenceAdapter.java | 59 ++++++++ .../db/changelog/001-create-events-table.xml | 44 ++++++ .../db/changelog/db.changelog-master.xml | 1 + .../EventPersistenceAdapterTest.java | 97 +++++++++++++ 6 files changed, 348 insertions(+) create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java create mode 100644 backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java create mode 100644 backend/src/main/resources/db/changelog/001-create-events-table.xml create mode 100644 backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java 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 new file mode 100644 index 0000000..d644503 --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaEntity.java @@ -0,0 +1,135 @@ +package de.fete.adapter.out.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** JPA entity mapping to the events table. */ +@Entity +@Table(name = "events") +public class EventJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_token", nullable = false, unique = true) + private UUID eventToken; + + @Column(name = "organizer_token", nullable = false, unique = true) + private UUID organizerToken; + + @Column(nullable = false, length = 200) + private String title; + + @Column(length = 2000) + private String description; + + @Column(name = "date_time", nullable = false) + private OffsetDateTime dateTime; + + @Column(length = 500) + private String location; + + @Column(name = "expiry_date", nullable = false) + private LocalDate expiryDate; + + @Column(name = "created_at", nullable = false) + 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. */ + public UUID getEventToken() { + return eventToken; + } + + /** Sets the public event token. */ + public void setEventToken(UUID eventToken) { + this.eventToken = eventToken; + } + + /** Returns the secret organizer token. */ + 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. */ + 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. */ + 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/adapter/out/persistence/EventJpaRepository.java b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java new file mode 100644 index 0000000..3770afd --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java @@ -0,0 +1,12 @@ +package de.fete.adapter.out.persistence; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +/** Spring Data JPA repository for event entities. */ +public interface EventJpaRepository extends JpaRepository { + + /** Finds an event by its public event token. */ + Optional findByEventToken(UUID eventToken); +} 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 new file mode 100644 index 0000000..360e099 --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java @@ -0,0 +1,59 @@ +package de.fete.adapter.out.persistence; + +import de.fete.domain.model.Event; +import de.fete.domain.port.out.EventRepository; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Repository; + +/** Persistence adapter implementing the EventRepository outbound port. */ +@Repository +public class EventPersistenceAdapter implements EventRepository { + + private final EventJpaRepository jpaRepository; + + /** Creates a new adapter with the given JPA repository. */ + public EventPersistenceAdapter(EventJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public Event save(Event event) { + EventJpaEntity entity = toEntity(event); + EventJpaEntity saved = jpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findByEventToken(UUID eventToken) { + return jpaRepository.findByEventToken(eventToken).map(this::toDomain); + } + + private EventJpaEntity toEntity(Event event) { + var entity = new EventJpaEntity(); + entity.setId(event.getId()); + entity.setEventToken(event.getEventToken()); + entity.setOrganizerToken(event.getOrganizerToken()); + entity.setTitle(event.getTitle()); + entity.setDescription(event.getDescription()); + entity.setDateTime(event.getDateTime()); + entity.setLocation(event.getLocation()); + entity.setExpiryDate(event.getExpiryDate()); + entity.setCreatedAt(event.getCreatedAt()); + return entity; + } + + private Event toDomain(EventJpaEntity entity) { + var event = new Event(); + event.setId(entity.getId()); + event.setEventToken(entity.getEventToken()); + event.setOrganizerToken(entity.getOrganizerToken()); + event.setTitle(entity.getTitle()); + event.setDescription(entity.getDescription()); + event.setDateTime(entity.getDateTime()); + event.setLocation(entity.getLocation()); + event.setExpiryDate(entity.getExpiryDate()); + event.setCreatedAt(entity.getCreatedAt()); + return event; + } +} diff --git a/backend/src/main/resources/db/changelog/001-create-events-table.xml b/backend/src/main/resources/db/changelog/001-create-events-table.xml new file mode 100644 index 0000000..7b6be51 --- /dev/null +++ b/backend/src/main/resources/db/changelog/001-create-events-table.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index a4fbf00..5e156aa 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -6,5 +6,6 @@ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> + 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 new file mode 100644 index 0000000..c743872 --- /dev/null +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterTest.java @@ -0,0 +1,97 @@ +package de.fete.adapter.out.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.fete.TestcontainersConfig; +import de.fete.domain.model.Event; +import de.fete.domain.port.out.EventRepository; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@Import(TestcontainersConfig.class) +class EventPersistenceAdapterTest { + + @Autowired + private EventRepository eventRepository; + + @Test + void saveReturnsEventWithGeneratedId() { + Event event = buildEvent(); + + Event saved = eventRepository.save(event); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getTitle()).isEqualTo("Test Event"); + } + + @Test + void savedEventIsFoundByEventToken() { + Event event = buildEvent(); + Event saved = eventRepository.save(event); + + Optional found = eventRepository.findByEventToken(saved.getEventToken()); + + assertThat(found).isPresent(); + assertThat(found.get().getTitle()).isEqualTo("Test Event"); + assertThat(found.get().getId()).isEqualTo(saved.getId()); + } + + @Test + void findByUnknownEventTokenReturnsEmpty() { + Optional found = eventRepository.findByEventToken(UUID.randomUUID()); + + assertThat(found).isEmpty(); + } + + @Test + void allFieldsRoundTripCorrectly() { + OffsetDateTime dateTime = + OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)); + LocalDate expiryDate = LocalDate.of(2026, 7, 15); + OffsetDateTime createdAt = + OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); + + var event = new Event(); + event.setEventToken(UUID.randomUUID()); + event.setOrganizerToken(UUID.randomUUID()); + event.setTitle("Full Event"); + event.setDescription("A detailed description"); + event.setDateTime(dateTime); + event.setLocation("Berlin, Germany"); + event.setExpiryDate(expiryDate); + event.setCreatedAt(createdAt); + + Event saved = eventRepository.save(event); + Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow(); + + assertThat(found.getEventToken()).isEqualTo(event.getEventToken()); + assertThat(found.getOrganizerToken()).isEqualTo(event.getOrganizerToken()); + assertThat(found.getTitle()).isEqualTo("Full Event"); + assertThat(found.getDescription()).isEqualTo("A detailed description"); + assertThat(found.getDateTime().toInstant()).isEqualTo(dateTime.toInstant()); + assertThat(found.getLocation()).isEqualTo("Berlin, Germany"); + assertThat(found.getExpiryDate()).isEqualTo(expiryDate); + assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant()); + } + + private Event buildEvent() { + var event = new Event(); + event.setEventToken(UUID.randomUUID()); + event.setOrganizerToken(UUID.randomUUID()); + event.setTitle("Test Event"); + event.setDescription("Test description"); + event.setDateTime(OffsetDateTime.now().plusDays(7)); + event.setLocation("Somewhere"); + event.setExpiryDate(LocalDate.now().plusDays(30)); + event.setCreatedAt(OffsetDateTime.now()); + return event; + } +}