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 307e47c..521d4fd 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 @@ -8,8 +8,9 @@ import de.fete.adapter.in.web.model.CreateRsvpRequest; import de.fete.adapter.in.web.model.CreateRsvpResponse; import de.fete.adapter.in.web.model.GetAttendeesResponse; import de.fete.adapter.in.web.model.GetEventResponse; -import de.fete.application.service.EventNotFoundException; -import de.fete.application.service.InvalidTimezoneException; +import de.fete.adapter.in.web.model.PatchEventRequest; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.InvalidTimezoneException; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; @@ -22,6 +23,7 @@ import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.CreateRsvpUseCase; import de.fete.domain.port.in.GetAttendeesUseCase; import de.fete.domain.port.in.GetEventUseCase; +import de.fete.domain.port.in.UpdateEventUseCase; import java.time.DateTimeException; import java.time.ZoneId; import java.util.List; @@ -40,6 +42,7 @@ public class EventController implements EventsApi { private final CancelRsvpUseCase cancelRsvpUseCase; private final CountAttendeesByEventUseCase countAttendeesByEventUseCase; private final GetAttendeesUseCase getAttendeesUseCase; + private final UpdateEventUseCase updateEventUseCase; /** Creates a new controller with the given use cases. */ public EventController( @@ -48,13 +51,15 @@ public class EventController implements EventsApi { CreateRsvpUseCase createRsvpUseCase, CancelRsvpUseCase cancelRsvpUseCase, CountAttendeesByEventUseCase countAttendeesByEventUseCase, - GetAttendeesUseCase getAttendeesUseCase) { + GetAttendeesUseCase getAttendeesUseCase, + UpdateEventUseCase updateEventUseCase) { this.createEventUseCase = createEventUseCase; this.getEventUseCase = getEventUseCase; this.createRsvpUseCase = createRsvpUseCase; this.cancelRsvpUseCase = cancelRsvpUseCase; this.countAttendeesByEventUseCase = countAttendeesByEventUseCase; this.getAttendeesUseCase = getAttendeesUseCase; + this.updateEventUseCase = updateEventUseCase; } @Override @@ -73,11 +78,11 @@ public class EventController implements EventsApi { Event event = createEventUseCase.createEvent(command); var response = new CreateEventResponse(); - response.setEventToken(event.getEventToken().value()); - response.setOrganizerToken(event.getOrganizerToken().value()); - response.setTitle(event.getTitle()); - response.setDateTime(event.getDateTime()); - response.setTimezone(event.getTimezone().getId()); + response.setEventToken(event.eventToken().value()); + response.setOrganizerToken(event.organizerToken().value()); + response.setTitle(event.title()); + response.setDateTime(event.dateTime()); + response.setTimezone(event.timezone().getId()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -89,18 +94,31 @@ public class EventController implements EventsApi { .orElseThrow(() -> new EventNotFoundException(eventToken)); var response = new GetEventResponse(); - response.setEventToken(event.getEventToken().value()); - response.setTitle(event.getTitle()); - response.setDescription(event.getDescription()); - response.setDateTime(event.getDateTime()); - response.setTimezone(event.getTimezone().getId()); - response.setLocation(event.getLocation()); + response.setEventToken(event.eventToken().value()); + response.setTitle(event.title()); + response.setDescription(event.description()); + response.setDateTime(event.dateTime()); + response.setTimezone(event.timezone().getId()); + response.setLocation(event.location()); response.setAttendeeCount( (int) countAttendeesByEventUseCase.countByEvent(evtToken)); + response.setCancelled(event.cancelled()); + response.setCancellationReason(event.cancellationReason()); return ResponseEntity.ok(response); } + @Override + public ResponseEntity patchEvent( + UUID eventToken, UUID organizerToken, PatchEventRequest request) { + updateEventUseCase.cancelEvent( + new EventToken(eventToken), + new OrganizerToken(organizerToken), + request.getCancelled(), + request.getCancellationReason()); + return ResponseEntity.noContent().build(); + } + @Override public ResponseEntity getAttendees( UUID eventToken, UUID organizerToken) { @@ -127,8 +145,8 @@ public class EventController implements EventsApi { Rsvp rsvp = createRsvpUseCase.createRsvp(evtToken, createRsvpRequest.getName()); var response = new CreateRsvpResponse(); - response.setRsvpToken(rsvp.getRsvpToken().value()); - response.setName(rsvp.getName()); + response.setRsvpToken(rsvp.rsvpToken().value()); + response.setName(rsvp.name()); return ResponseEntity.status(HttpStatus.CREATED).body(response); } 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 30bcd2d..008fafd 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,11 +1,13 @@ package de.fete.adapter.in.web; -import de.fete.application.service.EventExpiredException; -import de.fete.application.service.EventNotFoundException; -import de.fete.application.service.ExpiryDateBeforeEventException; -import de.fete.application.service.ExpiryDateInPastException; -import de.fete.application.service.InvalidOrganizerTokenException; -import de.fete.application.service.InvalidTimezoneException; +import de.fete.application.service.exception.EventAlreadyCancelledException; +import de.fete.application.service.exception.EventCancelledException; +import de.fete.application.service.exception.EventExpiredException; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.ExpiryDateBeforeEventException; +import de.fete.application.service.exception.ExpiryDateInPastException; +import de.fete.application.service.exception.InvalidOrganizerTokenException; +import de.fete.application.service.exception.InvalidTimezoneException; import java.net.URI; import java.util.List; import java.util.Map; @@ -75,6 +77,32 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { .body(problemDetail); } + /** Handles attempt to cancel an already cancelled event. */ + @ExceptionHandler(EventAlreadyCancelledException.class) + public ResponseEntity handleEventAlreadyCancelled( + EventAlreadyCancelledException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Event Already Cancelled"); + problemDetail.setType(URI.create("urn:problem-type:event-already-cancelled")); + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + + /** Handles RSVP on cancelled event. */ + @ExceptionHandler(EventCancelledException.class) + public ResponseEntity handleEventCancelled( + EventCancelledException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Event Cancelled"); + problemDetail.setType(URI.create("urn:problem-type:event-cancelled")); + return ResponseEntity.status(HttpStatus.CONFLICT) + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + /** Handles RSVP on expired event. */ @ExceptionHandler(EventExpiredException.class) public ResponseEntity handleEventExpired( diff --git a/backend/src/main/java/de/fete/adapter/in/web/SpaController.java b/backend/src/main/java/de/fete/adapter/in/web/SpaController.java index e57e7f2..fdc53aa 100644 --- a/backend/src/main/java/de/fete/adapter/in/web/SpaController.java +++ b/backend/src/main/java/de/fete/adapter/in/web/SpaController.java @@ -86,11 +86,11 @@ public class SpaController { private Map buildEventMeta(Event event, String baseUrl) { var tags = new LinkedHashMap(); - String title = truncateTitle(event.getTitle()); + String title = truncateTitle(event.title()); String description = formatDescription(event); tags.put("og:title", title); tags.put("og:description", description); - tags.put("og:url", baseUrl + "/events/" + event.getEventToken().value()); + tags.put("og:url", baseUrl + "/events/" + event.eventToken().value()); tags.put("og:type", "website"); tags.put("og:site_name", GENERIC_TITLE); tags.put("og:image", baseUrl + "/og-image.png"); @@ -138,16 +138,16 @@ public class SpaController { } private String formatDescription(Event event) { - ZonedDateTime zoned = event.getDateTime().atZoneSameInstant(event.getTimezone()); + ZonedDateTime zoned = event.dateTime().atZoneSameInstant(event.timezone()); var sb = new StringBuilder(); sb.append("πŸ“… ").append(zoned.format(DATE_FORMAT)); - if (event.getLocation() != null && !event.getLocation().isBlank()) { - sb.append(" Β· πŸ“ ").append(event.getLocation()); + if (event.location() != null && !event.location().isBlank()) { + sb.append(" Β· πŸ“ ").append(event.location()); } - if (event.getDescription() != null && !event.getDescription().isBlank()) { - sb.append(" β€” ").append(event.getDescription()); + if (event.description() != null && !event.description().isBlank()) { + sb.append(" β€” ").append(event.description()); } String result = sb.toString(); 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 04a33b0..48ef235 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 @@ -46,6 +46,12 @@ public class EventJpaEntity { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; + @Column(name = "cancelled", nullable = false) + private boolean cancelled; + + @Column(name = "cancellation_reason", length = 2000) + private String cancellationReason; + /** Returns the internal database ID. */ public Long getId() { return id; @@ -145,4 +151,24 @@ public class EventJpaEntity { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + /** Returns whether the event is cancelled. */ + public boolean isCancelled() { + return cancelled; + } + + /** Sets the cancelled flag. */ + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + /** Returns the cancellation reason. */ + public String getCancellationReason() { + return cancellationReason; + } + + /** Sets the cancellation reason. */ + public void setCancellationReason(String cancellationReason) { + this.cancellationReason = cancellationReason; + } } 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 be51d9c..02918e3 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 @@ -38,31 +38,34 @@ public class EventPersistenceAdapter implements EventRepository { private EventJpaEntity toEntity(Event event) { var entity = new EventJpaEntity(); - entity.setId(event.getId()); - entity.setEventToken(event.getEventToken().value()); - entity.setOrganizerToken(event.getOrganizerToken().value()); - 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()); + entity.setId(event.id()); + entity.setEventToken(event.eventToken().value()); + entity.setOrganizerToken(event.organizerToken().value()); + entity.setTitle(event.title()); + entity.setDescription(event.description()); + entity.setDateTime(event.dateTime()); + entity.setTimezone(event.timezone().getId()); + entity.setLocation(event.location()); + entity.setExpiryDate(event.expiryDate()); + entity.setCreatedAt(event.createdAt()); + entity.setCancelled(event.cancelled()); + entity.setCancellationReason(event.cancellationReason()); return entity; } private Event toDomain(EventJpaEntity entity) { - var event = new Event(); - event.setId(entity.getId()); - event.setEventToken(new EventToken(entity.getEventToken())); - event.setOrganizerToken(new OrganizerToken(entity.getOrganizerToken())); - 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()); - return event; + return new Event( + entity.getId(), + new EventToken(entity.getEventToken()), + new OrganizerToken(entity.getOrganizerToken()), + entity.getTitle(), + entity.getDescription(), + entity.getDateTime(), + ZoneId.of(entity.getTimezone()), + entity.getLocation(), + entity.getExpiryDate(), + entity.getCreatedAt(), + entity.isCancelled(), + entity.getCancellationReason()); } } diff --git a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java index 6cc3c9a..ec274f8 100644 --- a/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java +++ b/backend/src/main/java/de/fete/adapter/out/persistence/RsvpPersistenceAdapter.java @@ -43,19 +43,18 @@ public class RsvpPersistenceAdapter implements RsvpRepository { private RsvpJpaEntity toEntity(Rsvp rsvp) { var entity = new RsvpJpaEntity(); - entity.setId(rsvp.getId()); - entity.setRsvpToken(rsvp.getRsvpToken().value()); - entity.setEventId(rsvp.getEventId()); - entity.setName(rsvp.getName()); + entity.setId(rsvp.id()); + entity.setRsvpToken(rsvp.rsvpToken().value()); + entity.setEventId(rsvp.eventId()); + entity.setName(rsvp.name()); return entity; } private Rsvp toDomain(RsvpJpaEntity entity) { - var rsvp = new Rsvp(); - rsvp.setId(entity.getId()); - rsvp.setRsvpToken(new RsvpToken(entity.getRsvpToken())); - rsvp.setEventId(entity.getEventId()); - rsvp.setName(entity.getName()); - return rsvp; + return new Rsvp( + entity.getId(), + new RsvpToken(entity.getRsvpToken()), + entity.getEventId(), + entity.getName()); } } 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 70d226d..8eae4dd 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -1,21 +1,26 @@ package de.fete.application.service; +import de.fete.application.service.exception.EventAlreadyCancelledException; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.InvalidOrganizerTokenException; import de.fete.domain.model.CreateEventCommand; import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; import de.fete.domain.model.OrganizerToken; import de.fete.domain.port.in.CreateEventUseCase; import de.fete.domain.port.in.GetEventUseCase; +import de.fete.domain.port.in.UpdateEventUseCase; import de.fete.domain.port.out.EventRepository; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; import java.util.Optional; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** Application service implementing event creation and retrieval. */ @Service -public class EventService implements CreateEventUseCase, GetEventUseCase { +public class EventService implements CreateEventUseCase, GetEventUseCase, UpdateEventUseCase { private static final int EXPIRY_DAYS_AFTER_EVENT = 7; @@ -32,16 +37,19 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { public Event createEvent(CreateEventCommand command) { LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT); - var event = new Event(); - event.setEventToken(EventToken.generate()); - event.setOrganizerToken(OrganizerToken.generate()); - event.setTitle(command.title()); - event.setDescription(command.description()); - event.setDateTime(command.dateTime()); - event.setTimezone(command.timezone()); - event.setLocation(command.location()); - event.setExpiryDate(expiryDate); - event.setCreatedAt(OffsetDateTime.now(clock)); + var event = new Event( + null, + EventToken.generate(), + OrganizerToken.generate(), + command.title(), + command.description(), + command.dateTime(), + command.timezone(), + command.location(), + expiryDate, + OffsetDateTime.now(clock), + false, + null); return eventRepository.save(event); } @@ -50,4 +58,27 @@ public class EventService implements CreateEventUseCase, GetEventUseCase { public Optional getByEventToken(EventToken eventToken) { return eventRepository.findByEventToken(eventToken); } + + @Transactional + @Override + public void cancelEvent( + EventToken eventToken, OrganizerToken organizerToken, + Boolean cancelled, String reason) { + if (!Boolean.TRUE.equals(cancelled)) { + return; + } + + Event event = eventRepository.findByEventToken(eventToken) + .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + + if (!event.organizerToken().equals(organizerToken)) { + throw new InvalidOrganizerTokenException(); + } + + if (event.cancelled()) { + throw new EventAlreadyCancelledException(eventToken.value()); + } + + eventRepository.save(event.withCancellation(true, reason)); + } } diff --git a/backend/src/main/java/de/fete/application/service/RsvpService.java b/backend/src/main/java/de/fete/application/service/RsvpService.java index 56632dd..d649851 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -1,5 +1,9 @@ package de.fete.application.service; +import de.fete.application.service.exception.EventCancelledException; +import de.fete.application.service.exception.EventExpiredException; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.InvalidOrganizerTokenException; import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; import de.fete.domain.model.OrganizerToken; @@ -42,14 +46,15 @@ public class RsvpService Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); - if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) { + if (event.cancelled()) { + throw new EventCancelledException(eventToken.value()); + } + + if (!event.expiryDate().isAfter(LocalDate.now(clock))) { throw new EventExpiredException(eventToken.value()); } - var rsvp = new Rsvp(); - rsvp.setRsvpToken(RsvpToken.generate()); - rsvp.setEventId(event.getId()); - rsvp.setName(name.strip()); + var rsvp = new Rsvp(null, RsvpToken.generate(), event.id(), name.strip()); return rsvpRepository.save(rsvp); } @@ -59,14 +64,14 @@ public class RsvpService public void cancelRsvp(EventToken eventToken, RsvpToken rsvpToken) { eventRepository.findByEventToken(eventToken) .ifPresent(event -> - rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)); + rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)); } @Override public long countByEvent(EventToken eventToken) { Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); - return rsvpRepository.countByEventId(event.getId()); + return rsvpRepository.countByEventId(event.id()); } @Override @@ -74,12 +79,12 @@ public class RsvpService Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); - if (!event.getOrganizerToken().equals(organizerToken)) { + if (!event.organizerToken().equals(organizerToken)) { throw new InvalidOrganizerTokenException(); } - return rsvpRepository.findByEventId(event.getId()).stream() - .map(Rsvp::getName) + return rsvpRepository.findByEventId(event.id()).stream() + .map(Rsvp::name) .toList(); } } diff --git a/backend/src/main/java/de/fete/application/service/exception/EventAlreadyCancelledException.java b/backend/src/main/java/de/fete/application/service/exception/EventAlreadyCancelledException.java new file mode 100644 index 0000000..71d9395 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/exception/EventAlreadyCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service.exception; + +import java.util.UUID; + +/** Thrown when attempting to cancel an event that is already cancelled. */ +public class EventAlreadyCancelledException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventAlreadyCancelledException(UUID eventToken) { + super("Event is already cancelled: " + eventToken); + } +} diff --git a/backend/src/main/java/de/fete/application/service/exception/EventCancelledException.java b/backend/src/main/java/de/fete/application/service/exception/EventCancelledException.java new file mode 100644 index 0000000..0946d0a --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/exception/EventCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service.exception; + +import java.util.UUID; + +/** Thrown when an RSVP is attempted on a cancelled event. */ +public class EventCancelledException extends RuntimeException { + + /** Creates a new exception for the given event token. */ + public EventCancelledException(UUID eventToken) { + super("Event is cancelled: " + eventToken); + } +} diff --git a/backend/src/main/java/de/fete/application/service/EventExpiredException.java b/backend/src/main/java/de/fete/application/service/exception/EventExpiredException.java similarity index 86% rename from backend/src/main/java/de/fete/application/service/EventExpiredException.java rename to backend/src/main/java/de/fete/application/service/exception/EventExpiredException.java index 374830d..5c3ef6b 100644 --- a/backend/src/main/java/de/fete/application/service/EventExpiredException.java +++ b/backend/src/main/java/de/fete/application/service/exception/EventExpiredException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; import java.util.UUID; diff --git a/backend/src/main/java/de/fete/application/service/EventNotFoundException.java b/backend/src/main/java/de/fete/application/service/exception/EventNotFoundException.java similarity index 86% rename from backend/src/main/java/de/fete/application/service/EventNotFoundException.java rename to backend/src/main/java/de/fete/application/service/exception/EventNotFoundException.java index 6e3025f..b9b895b 100644 --- a/backend/src/main/java/de/fete/application/service/EventNotFoundException.java +++ b/backend/src/main/java/de/fete/application/service/exception/EventNotFoundException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; import java.util.UUID; diff --git a/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java b/backend/src/main/java/de/fete/application/service/exception/ExpiryDateBeforeEventException.java similarity index 90% rename from backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java rename to backend/src/main/java/de/fete/application/service/exception/ExpiryDateBeforeEventException.java index ccccef3..b4aba0a 100644 --- a/backend/src/main/java/de/fete/application/service/ExpiryDateBeforeEventException.java +++ b/backend/src/main/java/de/fete/application/service/exception/ExpiryDateBeforeEventException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; import java.time.LocalDate; import java.time.OffsetDateTime; diff --git a/backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java b/backend/src/main/java/de/fete/application/service/exception/ExpiryDateInPastException.java similarity index 91% rename from backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java rename to backend/src/main/java/de/fete/application/service/exception/ExpiryDateInPastException.java index 77808d4..ba254ae 100644 --- a/backend/src/main/java/de/fete/application/service/ExpiryDateInPastException.java +++ b/backend/src/main/java/de/fete/application/service/exception/ExpiryDateInPastException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; import java.time.LocalDate; diff --git a/backend/src/main/java/de/fete/application/service/InvalidOrganizerTokenException.java b/backend/src/main/java/de/fete/application/service/exception/InvalidOrganizerTokenException.java similarity index 85% rename from backend/src/main/java/de/fete/application/service/InvalidOrganizerTokenException.java rename to backend/src/main/java/de/fete/application/service/exception/InvalidOrganizerTokenException.java index 0576a81..b25d68f 100644 --- a/backend/src/main/java/de/fete/application/service/InvalidOrganizerTokenException.java +++ b/backend/src/main/java/de/fete/application/service/exception/InvalidOrganizerTokenException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; /** Thrown when an invalid organizer token is provided. */ public class InvalidOrganizerTokenException extends RuntimeException { diff --git a/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java b/backend/src/main/java/de/fete/application/service/exception/InvalidTimezoneException.java similarity index 86% rename from backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java rename to backend/src/main/java/de/fete/application/service/exception/InvalidTimezoneException.java index 4269804..67cdfc7 100644 --- a/backend/src/main/java/de/fete/application/service/InvalidTimezoneException.java +++ b/backend/src/main/java/de/fete/application/service/exception/InvalidTimezoneException.java @@ -1,4 +1,4 @@ -package de.fete.application.service; +package de.fete.application.service.exception; /** Thrown when an invalid IANA timezone ID is provided. */ public class InvalidTimezoneException extends RuntimeException { diff --git a/backend/src/main/java/de/fete/application/service/exception/package-info.java b/backend/src/main/java/de/fete/application/service/exception/package-info.java new file mode 100644 index 0000000..6585c76 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/exception/package-info.java @@ -0,0 +1,4 @@ +/** + * Application-layer exceptions thrown by service use case implementations. + */ +package de.fete.application.service.exception; 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 27d2cf6..8c9a539 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -5,116 +5,26 @@ import java.time.OffsetDateTime; import java.time.ZoneId; /** Domain entity representing an event. */ -public class Event { +public record Event( + Long id, + EventToken eventToken, + OrganizerToken organizerToken, + String title, + String description, + OffsetDateTime dateTime, + ZoneId timezone, + String location, + LocalDate expiryDate, + OffsetDateTime createdAt, + boolean cancelled, + String cancellationReason +) { - private Long id; - private EventToken eventToken; - private OrganizerToken organizerToken; - private String title; - private String description; - private OffsetDateTime dateTime; - private ZoneId timezone; - 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. */ - public EventToken getEventToken() { - return eventToken; - } - - /** Sets the public event token. */ - public void setEventToken(EventToken eventToken) { - this.eventToken = eventToken; - } - - /** Returns the secret organizer token. */ - public OrganizerToken getOrganizerToken() { - return organizerToken; - } - - /** Sets the secret organizer token. */ - public void setOrganizerToken(OrganizerToken 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 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; - } - - /** 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; + /** Returns a copy of this event with cancellation applied. */ + public Event withCancellation(boolean cancelled, String cancellationReason) { + return new Event( + id, eventToken, organizerToken, title, description, + dateTime, timezone, location, expiryDate, createdAt, + cancelled, cancellationReason); } } diff --git a/backend/src/main/java/de/fete/domain/model/Rsvp.java b/backend/src/main/java/de/fete/domain/model/Rsvp.java index 53285db..535af47 100644 --- a/backend/src/main/java/de/fete/domain/model/Rsvp.java +++ b/backend/src/main/java/de/fete/domain/model/Rsvp.java @@ -1,50 +1,9 @@ package de.fete.domain.model; /** Domain entity representing an RSVP. */ -public class Rsvp { - - private Long id; - private RsvpToken rsvpToken; - private Long eventId; - private String name; - - /** 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 RSVP token. */ - public RsvpToken getRsvpToken() { - return rsvpToken; - } - - /** Sets the RSVP token. */ - public void setRsvpToken(RsvpToken rsvpToken) { - this.rsvpToken = rsvpToken; - } - - /** Returns the event ID this RSVP belongs to. */ - public Long getEventId() { - return eventId; - } - - /** Sets the event ID. */ - public void setEventId(Long eventId) { - this.eventId = eventId; - } - - /** Returns the guest's display name. */ - public String getName() { - return name; - } - - /** Sets the guest's display name. */ - public void setName(String name) { - this.name = name; - } -} +public record Rsvp( + Long id, + RsvpToken rsvpToken, + Long eventId, + String name +) {} diff --git a/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java b/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java new file mode 100644 index 0000000..6038dfa --- /dev/null +++ b/backend/src/main/java/de/fete/domain/port/in/UpdateEventUseCase.java @@ -0,0 +1,13 @@ +package de.fete.domain.port.in; + +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; + +/** Inbound port for updating an event. */ +public interface UpdateEventUseCase { + + /** Cancels the event identified by the given token. */ + void cancelEvent( + EventToken eventToken, OrganizerToken organizerToken, + Boolean cancelled, String reason); +} diff --git a/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml b/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml new file mode 100644 index 0000000..85e788f --- /dev/null +++ b/backend/src/main/resources/db/changelog/004-add-cancellation-columns.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + 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 069351a..9c5e35b 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -9,5 +9,6 @@ + diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 190df2e..72a356b 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -184,6 +184,58 @@ paths: schema: $ref: "#/components/schemas/ProblemDetail" + patch: + operationId: patchEvent + summary: Update an event (currently cancel) + description: | + Partial update of an event resource. Currently the only supported operation + is cancellation (setting cancelled to true). Requires the organizer token. + Cancellation is irreversible. + tags: + - events + parameters: + - name: eventToken + in: path + required: true + schema: + type: string + format: uuid + description: Public event token + - name: organizerToken + in: query + required: true + schema: + type: string + format: uuid + description: Organizer token for authorization + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PatchEventRequest" + responses: + "204": + description: Event updated successfully + "403": + description: Invalid organizer token + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Event not found + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "409": + description: Event is already cancelled + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + components: schemas: CreateEventRequest: @@ -252,6 +304,7 @@ components: - dateTime - timezone - attendeeCount + - cancelled properties: eventToken: type: string @@ -284,6 +337,31 @@ components: minimum: 0 description: Number of confirmed attendees (attending=true) example: 12 + cancelled: + type: boolean + description: Whether the event has been cancelled + example: false + cancellationReason: + type: + - string + - "null" + description: Reason for cancellation, if provided + example: null + + PatchEventRequest: + type: object + required: + - cancelled + properties: + cancelled: + type: boolean + description: Set to true to cancel the event (irreversible) + example: true + cancellationReason: + type: string + maxLength: 2000 + description: Optional cancellation reason + example: "Unfortunately the venue is no longer available." CreateRsvpRequest: type: object diff --git a/backend/src/test/java/de/fete/HexagonalArchitectureTest.java b/backend/src/test/java/de/fete/HexagonalArchitectureTest.java index 90e2124..8620fe4 100644 --- a/backend/src/test/java/de/fete/HexagonalArchitectureTest.java +++ b/backend/src/test/java/de/fete/HexagonalArchitectureTest.java @@ -4,10 +4,14 @@ import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.Architectures.onionArchitecture; +import com.tngtech.archunit.core.domain.JavaClass; import com.tngtech.archunit.core.importer.ImportOption; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; @AnalyzeClasses(packages = "de.fete", importOptions = ImportOption.DoNotIncludeTests.class) class HexagonalArchitectureTest { @@ -65,4 +69,24 @@ class HexagonalArchitectureTest { static final ArchRule webAdapterMustNotDependOnOutboundPorts = noClasses() .that().resideInAPackage("de.fete.adapter.in.web..") .should().dependOnClassesThat().resideInAPackage("de.fete.domain.port.out.."); + + @ArchTest + static final ArchRule domainModelsMustBeRecords = classes() + .that().resideInAPackage("de.fete.domain.model..") + .and().doNotHaveSimpleName("package-info") + .should(beRecords()); + + private static ArchCondition beRecords() { + return new ArchCondition<>("be records") { + @Override + public void check(JavaClass javaClass, + ConditionEvents events) { + boolean isRecord = javaClass.reflect().isRecord(); + if (!isRecord) { + events.add(SimpleConditionEvent.violated(javaClass, + javaClass.getFullName() + " is not a record")); + } + } + }; + } } 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 7a44f46..d94aaca 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 @@ -3,6 +3,7 @@ package de.fete.adapter.in.web; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; 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; @@ -21,6 +22,7 @@ import de.fete.adapter.out.persistence.RsvpJpaRepository; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Map; import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -431,6 +433,147 @@ class EventControllerIntegrationTest { .andExpect(jsonPath("$.attendeeCount").value(1)); } + // --- Cancel Event tests --- + + @Test + void cancelEventReturns204AndPersists() throws Exception { + EventJpaEntity event = seedEvent( + "Cancel Me", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of( + "cancelled", true, + "cancellationReason", "Venue closed"); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNoContent()); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(event.getEventToken()).orElseThrow(); + assertThat(persisted.isCancelled()).isTrue(); + assertThat(persisted.getCancellationReason()).isEqualTo("Venue closed"); + } + + @Test + void cancelEventWithoutReasonReturns204() throws Exception { + EventJpaEntity event = seedEvent( + "Cancel No Reason", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNoContent()); + + EventJpaEntity persisted = jpaRepository + .findByEventToken(event.getEventToken()).orElseThrow(); + assertThat(persisted.isCancelled()).isTrue(); + assertThat(persisted.getCancellationReason()).isNull(); + } + + @Test + void cancelEventWithWrongOrganizerTokenReturns403() throws Exception { + EventJpaEntity event = seedEvent( + "Wrong Token", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:invalid-organizer-token")); + + assertThat(jpaRepository.findByEventToken(event.getEventToken()) + .orElseThrow().isCancelled()).isFalse(); + } + + @Test + void cancelEventNotFoundReturns404() throws Exception { + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + UUID.randomUUID() + + "?organizerToken=" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found")); + } + + @Test + void cancelAlreadyCancelledEventReturns409() throws Exception { + EventJpaEntity event = seedCancelledEvent("Already Cancelled"); + + var body = Map.of("cancelled", true); + + mockMvc.perform(patch("/api/events/" + event.getEventToken() + + "?organizerToken=" + event.getOrganizerToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-already-cancelled")); + } + + @Test + void getEventReturnsCancelledFields() throws Exception { + EventJpaEntity event = seedCancelledEvent("Weather Event"); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cancelled").value(true)) + .andExpect(jsonPath("$.cancellationReason").value("Cancelled")); + } + + @Test + void getEventReturnsNotCancelledByDefault() throws Exception { + EventJpaEntity event = seedEvent( + "Active Event", null, "Europe/Berlin", null, LocalDate.now().plusDays(30)); + + mockMvc.perform(get("/api/events/" + event.getEventToken())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.cancelled").value(false)) + .andExpect(jsonPath("$.cancellationReason").doesNotExist()); + } + + @Test + void createRsvpOnCancelledEventReturns409() throws Exception { + EventJpaEntity event = seedCancelledEvent("Cancelled RSVP"); + long countBefore = rsvpJpaRepository.count(); + + var request = new CreateRsvpRequest().name("Late Guest"); + + mockMvc.perform(post("/api/events/" + event.getEventToken() + "/rsvps") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:event-cancelled")); + + assertThat(rsvpJpaRepository.count()).isEqualTo(countBefore); + } + + private EventJpaEntity seedCancelledEvent(String title) { + var entity = new EventJpaEntity(); + entity.setEventToken(UUID.randomUUID()); + entity.setOrganizerToken(UUID.randomUUID()); + entity.setTitle(title); + entity.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); + entity.setTimezone("Europe/Berlin"); + entity.setExpiryDate(LocalDate.now().plusDays(30)); + entity.setCreatedAt(OffsetDateTime.now()); + entity.setCancelled(true); + entity.setCancellationReason("Cancelled"); + return jpaRepository.save(entity); + } + private UUID seedRsvpAndGetToken(EventJpaEntity event, String name) { var rsvp = new RsvpJpaEntity(); UUID token = UUID.randomUUID(); diff --git a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java index f926949..2a0b3b6 100644 --- a/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java +++ b/backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java @@ -42,7 +42,7 @@ class EventPersistenceAdapterIntegrationTest { eventRepository.deleteExpired(); - assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent(); + assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent(); } @Test @@ -52,7 +52,7 @@ class EventPersistenceAdapterIntegrationTest { eventRepository.deleteExpired(); - assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent(); + assertThat(eventRepository.findByEventToken(saved.eventToken())).isPresent(); } @Test @@ -66,16 +66,18 @@ class EventPersistenceAdapterIntegrationTest { } private Event buildEvent(String title, LocalDate expiryDate) { - var event = new Event(); - event.setEventToken(EventToken.generate()); - event.setOrganizerToken(OrganizerToken.generate()); - event.setTitle(title); - event.setDescription("Test description"); - event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); - event.setTimezone(ZoneId.of("Europe/Berlin")); - event.setLocation("Test Location"); - event.setExpiryDate(expiryDate); - event.setCreatedAt(OffsetDateTime.now()); - return event; + return new Event( + null, + EventToken.generate(), + OrganizerToken.generate(), + title, + "Test description", + OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), + ZoneId.of("Europe/Berlin"), + "Test Location", + expiryDate, + OffsetDateTime.now(), + false, + null); } } 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 d12c789..455d276 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 @@ -30,8 +30,8 @@ class EventPersistenceAdapterTest { Event saved = eventRepository.save(event); - assertThat(saved.getId()).isNotNull(); - assertThat(saved.getTitle()).isEqualTo("Test Event"); + assertThat(saved.id()).isNotNull(); + assertThat(saved.title()).isEqualTo("Test Event"); } @Test @@ -39,11 +39,11 @@ class EventPersistenceAdapterTest { Event event = buildEvent(); Event saved = eventRepository.save(event); - Optional found = eventRepository.findByEventToken(saved.getEventToken()); + Optional found = eventRepository.findByEventToken(saved.eventToken()); assertThat(found).isPresent(); - assertThat(found.get().getTitle()).isEqualTo("Test Event"); - assertThat(found.get().getId()).isEqualTo(saved.getId()); + assertThat(found.get().title()).isEqualTo("Test Event"); + assertThat(found.get().id()).isEqualTo(saved.id()); } @Test @@ -61,42 +61,47 @@ class EventPersistenceAdapterTest { OffsetDateTime createdAt = OffsetDateTime.of(2026, 3, 4, 12, 0, 0, 0, ZoneOffset.UTC); - var event = new Event(); - event.setEventToken(EventToken.generate()); - event.setOrganizerToken(OrganizerToken.generate()); - 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); + var event = new Event( + null, + EventToken.generate(), + OrganizerToken.generate(), + "Full Event", + "A detailed description", + dateTime, + ZoneId.of("Europe/Berlin"), + "Berlin, Germany", + expiryDate, + createdAt, + false, + null); Event saved = eventRepository.save(event); - Event found = eventRepository.findByEventToken(saved.getEventToken()).orElseThrow(); + Event found = eventRepository.findByEventToken(saved.eventToken()).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.getTimezone()).isEqualTo(ZoneId.of("Europe/Berlin")); - assertThat(found.getLocation()).isEqualTo("Berlin, Germany"); - assertThat(found.getExpiryDate()).isEqualTo(expiryDate); - assertThat(found.getCreatedAt().toInstant()).isEqualTo(createdAt.toInstant()); + assertThat(found.eventToken()).isEqualTo(event.eventToken()); + assertThat(found.organizerToken()).isEqualTo(event.organizerToken()); + assertThat(found.title()).isEqualTo("Full Event"); + assertThat(found.description()).isEqualTo("A detailed description"); + assertThat(found.dateTime().toInstant()).isEqualTo(dateTime.toInstant()); + assertThat(found.timezone()).isEqualTo(ZoneId.of("Europe/Berlin")); + assertThat(found.location()).isEqualTo("Berlin, Germany"); + assertThat(found.expiryDate()).isEqualTo(expiryDate); + assertThat(found.createdAt().toInstant()).isEqualTo(createdAt.toInstant()); } private Event buildEvent() { - var event = new Event(); - event.setEventToken(EventToken.generate()); - event.setOrganizerToken(OrganizerToken.generate()); - 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()); - return event; + return new Event( + null, + EventToken.generate(), + OrganizerToken.generate(), + "Test Event", + "Test description", + OffsetDateTime.now().plusDays(7), + ZoneId.of("Europe/Berlin"), + "Somewhere", + LocalDate.now().plusDays(30), + OffsetDateTime.now(), + false, + null); } } diff --git a/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java new file mode 100644 index 0000000..e76000d --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java @@ -0,0 +1,133 @@ +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import de.fete.application.service.exception.EventAlreadyCancelledException; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.InvalidOrganizerTokenException; +import de.fete.domain.model.Event; +import de.fete.domain.model.EventToken; +import de.fete.domain.model.OrganizerToken; +import de.fete.domain.port.out.EventRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Optional; +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 EventServiceCancelTest { + + 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 cancelEventDelegatesToDomainAndSaves() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null, + null, false, null); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + eventService.cancelEvent(eventToken, organizerToken, true, "Venue unavailable"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventRepository).save(captor.capture()); + assertThat(captor.getValue().cancelled()).isTrue(); + assertThat(captor.getValue().cancellationReason()).isEqualTo("Venue unavailable"); + } + + @Test + void cancelEventWithNullReason() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null, + null, false, null); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + when(eventRepository.save(any(Event.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + eventService.cancelEvent(eventToken, organizerToken, true, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); + verify(eventRepository).save(captor.capture()); + assertThat(captor.getValue().cancelled()).isTrue(); + assertThat(captor.getValue().cancellationReason()).isNull(); + } + + @Test + void cancelEventThrows404WhenNotFound() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null)) + .isInstanceOf(EventNotFoundException.class); + + verify(eventRepository, never()).save(any()); + } + + @Test + void cancelEventThrows403WhenWrongOrganizerToken() { + EventToken eventToken = EventToken.generate(); + OrganizerToken correctToken = OrganizerToken.generate(); + var event = new Event(null, eventToken, correctToken, null, null, null, null, null, null, + null, false, null); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + + final OrganizerToken wrongToken = OrganizerToken.generate(); + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, wrongToken, true, null)) + .isInstanceOf(InvalidOrganizerTokenException.class); + + verify(eventRepository, never()).save(any()); + } + + @Test + void cancelEventThrows409WhenAlreadyCancelled() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(null, eventToken, organizerToken, null, null, null, null, null, null, + null, true, null); + + when(eventRepository.findByEventToken(eventToken)) + .thenReturn(Optional.of(event)); + + assertThatThrownBy(() -> eventService.cancelEvent(eventToken, organizerToken, true, null)) + .isInstanceOf(EventAlreadyCancelledException.class); + + verify(eventRepository, never()).save(any()); + } +} 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 0225866..48d924c 100644 --- a/backend/src/test/java/de/fete/application/service/EventServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/EventServiceTest.java @@ -57,13 +57,13 @@ class EventServiceTest { Event result = eventService.createEvent(command); - assertThat(result.getTitle()).isEqualTo("Birthday Party"); - assertThat(result.getDescription()).isEqualTo("Come celebrate!"); - assertThat(result.getTimezone()).isEqualTo(ZONE); - assertThat(result.getLocation()).isEqualTo("Berlin"); - assertThat(result.getEventToken()).isNotNull(); - assertThat(result.getOrganizerToken()).isNotNull(); - assertThat(result.getCreatedAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE)); + assertThat(result.title()).isEqualTo("Birthday Party"); + assertThat(result.description()).isEqualTo("Come celebrate!"); + assertThat(result.timezone()).isEqualTo(ZONE); + assertThat(result.location()).isEqualTo("Berlin"); + assertThat(result.eventToken()).isNotNull(); + assertThat(result.organizerToken()).isNotNull(); + assertThat(result.createdAt()).isEqualTo(OffsetDateTime.ofInstant(FIXED_INSTANT, ZONE)); } @Test @@ -80,7 +80,7 @@ class EventServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(Event.class); verify(eventRepository, times(1)).save(captor.capture()); - assertThat(captor.getValue().getTitle()).isEqualTo("Test"); + assertThat(captor.getValue().title()).isEqualTo("Test"); } @Test @@ -96,7 +96,7 @@ class EventServiceTest { Event result = eventService.createEvent(command); - assertThat(result.getExpiryDate()).isEqualTo(eventDate.plusDays(7)); + assertThat(result.expiryDate()).isEqualTo(eventDate.plusDays(7)); } // --- GetEventUseCase tests (T004) --- @@ -104,16 +104,15 @@ class EventServiceTest { @Test void getByEventTokenReturnsEvent() { EventToken token = EventToken.generate(); - var event = new Event(); - event.setEventToken(token); - event.setTitle("Found Event"); + var event = new Event(null, token, null, "Found Event", null, null, null, null, null, null, + false, null); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.of(event)); Optional result = eventService.getByEventToken(token); assertThat(result).isPresent(); - assertThat(result.get().getTitle()).isEqualTo("Found Event"); + assertThat(result.get().title()).isEqualTo("Found Event"); } @Test @@ -142,6 +141,6 @@ class EventServiceTest { Event result = eventService.createEvent(command); - assertThat(result.getTimezone()).isEqualTo(ZoneId.of("America/New_York")); + assertThat(result.timezone()).isEqualTo(ZoneId.of("America/New_York")); } } diff --git a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java index ee60a0c..4d21a40 100644 --- a/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java +++ b/backend/src/test/java/de/fete/application/service/RsvpServiceTest.java @@ -6,6 +6,10 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import de.fete.application.service.exception.EventCancelledException; +import de.fete.application.service.exception.EventExpiredException; +import de.fete.application.service.exception.EventNotFoundException; +import de.fete.application.service.exception.InvalidOrganizerTokenException; import de.fete.domain.model.Event; import de.fete.domain.model.EventToken; import de.fete.domain.model.OrganizerToken; @@ -51,23 +55,23 @@ class RsvpServiceTest { @Test void createRsvpSucceedsForActiveEvent() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(rsvpRepository.save(any(Rsvp.class))) .thenAnswer(invocation -> invocation.getArgument(0)); Rsvp result = rsvpService.createRsvp(token, "Max Mustermann"); - assertThat(result.getName()).isEqualTo("Max Mustermann"); - assertThat(result.getRsvpToken()).isNotNull(); - assertThat(result.getEventId()).isEqualTo(event.getId()); + assertThat(result.name()).isEqualTo("Max Mustermann"); + assertThat(result.rsvpToken()).isNotNull(); + assertThat(result.eventId()).isEqualTo(event.id()); } @Test void createRsvpPersistsViaRepository() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(rsvpRepository.save(any(Rsvp.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -76,8 +80,8 @@ class RsvpServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(Rsvp.class); verify(rsvpRepository).save(captor.capture()); - assertThat(captor.getValue().getName()).isEqualTo("Test Guest"); - assertThat(captor.getValue().getEventId()).isEqualTo(event.getId()); + assertThat(captor.getValue().name()).isEqualTo("Test Guest"); + assertThat(captor.getValue().eventId()).isEqualTo(event.id()); } @Test @@ -91,22 +95,21 @@ class RsvpServiceTest { @Test void createRsvpTrimsName() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); when(rsvpRepository.save(any(Rsvp.class))) .thenAnswer(invocation -> invocation.getArgument(0)); Rsvp result = rsvpService.createRsvp(token, " Max "); - assertThat(result.getName()).isEqualTo("Max"); + assertThat(result.name()).isEqualTo("Max"); } @Test void createRsvpThrowsWhenEventExpired() { - var event = buildActiveEvent(); - event.setExpiryDate(TODAY.minusDays(1)); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.minusDays(1)); + EventToken token = event.eventToken(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) @@ -115,9 +118,8 @@ class RsvpServiceTest { @Test void createRsvpThrowsWhenEventExpiresToday() { - var event = buildActiveEvent(); - event.setExpiryDate(TODAY); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY); + EventToken token = event.eventToken(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); assertThatThrownBy(() -> rsvpService.createRsvp(token, "Late Guest")) @@ -126,12 +128,12 @@ class RsvpServiceTest { @Test void getAttendeeNamesReturnsNamesInOrder() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); - OrganizerToken orgToken = event.getOrganizerToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); + OrganizerToken orgToken = event.organizerToken(); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.of(event)); - when(rsvpRepository.findByEventId(event.getId())) + when(rsvpRepository.findByEventId(event.id())) .thenReturn(List.of( buildRsvp(1L, "Alice"), buildRsvp(2L, "Bob"), @@ -144,12 +146,12 @@ class RsvpServiceTest { @Test void getAttendeeNamesReturnsEmptyListWhenNoRsvps() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); - OrganizerToken orgToken = event.getOrganizerToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); + OrganizerToken orgToken = event.organizerToken(); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.of(event)); - when(rsvpRepository.findByEventId(event.getId())) + when(rsvpRepository.findByEventId(event.id())) .thenReturn(List.of()); List names = rsvpService.getAttendeeNames(token, orgToken); @@ -171,8 +173,8 @@ class RsvpServiceTest { @Test void getAttendeeNamesThrowsWhenOrganizerTokenInvalid() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); OrganizerToken wrongToken = OrganizerToken.generate(); when(eventRepository.findByEventToken(token)) .thenReturn(Optional.of(event)); @@ -183,38 +185,33 @@ class RsvpServiceTest { } private Rsvp buildRsvp(Long id, String name) { - var rsvp = new Rsvp(); - rsvp.setId(id); - rsvp.setRsvpToken(RsvpToken.generate()); - rsvp.setEventId(1L); - rsvp.setName(name); - return rsvp; + return new Rsvp(id, RsvpToken.generate(), 1L, name); } @Test void cancelRsvpDeletesWhenEventAndRsvpExist() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); RsvpToken rsvpToken = RsvpToken.generate(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); - when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(true); + when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(true); rsvpService.cancelRsvp(token, rsvpToken); - verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken); + verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken); } @Test void cancelRsvpSucceedsWhenRsvpNotFound() { - Event event = buildActiveEvent(); - EventToken token = event.getEventToken(); + Event event = buildActiveEvent(TODAY.plusDays(30)); + EventToken token = event.eventToken(); RsvpToken rsvpToken = RsvpToken.generate(); when(eventRepository.findByEventToken(token)).thenReturn(Optional.of(event)); - when(rsvpRepository.deleteByEventIdAndRsvpToken(event.getId(), rsvpToken)).thenReturn(false); + when(rsvpRepository.deleteByEventIdAndRsvpToken(event.id(), rsvpToken)).thenReturn(false); rsvpService.cancelRsvp(token, rsvpToken); - verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.getId(), rsvpToken); + verify(rsvpRepository).deleteByEventIdAndRsvpToken(event.id(), rsvpToken); } @Test @@ -226,16 +223,19 @@ class RsvpServiceTest { rsvpService.cancelRsvp(token, rsvpToken); } - private Event buildActiveEvent() { - var event = new Event(); - event.setId(1L); - event.setEventToken(EventToken.generate()); - event.setOrganizerToken(OrganizerToken.generate()); - event.setTitle("Test Event"); - event.setDateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2))); - event.setTimezone(ZONE); - event.setExpiryDate(TODAY.plusDays(30)); - event.setCreatedAt(OffsetDateTime.now()); - return event; + private Event buildActiveEvent(LocalDate expiryDate) { + return new Event( + 1L, + EventToken.generate(), + OrganizerToken.generate(), + "Test Event", + null, + OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)), + ZONE, + null, + expiryDate, + OffsetDateTime.now(), + false, + null); } } diff --git a/frontend/e2e/cancel-event.spec.ts b/frontend/e2e/cancel-event.spec.ts new file mode 100644 index 0000000..9ba73e3 --- /dev/null +++ b/frontend/e2e/cancel-event.spec.ts @@ -0,0 +1,162 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' +import type { StoredEvent } from '../src/composables/useEventStorage' + +const STORAGE_KEY = 'fete:events' + +const fullEvent = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + cancelled: false, + cancellationReason: null, +} + +const organizerToken = '550e8400-e29b-41d4-a716-446655440001' + +function seedEvents(events: StoredEvent[]): string { + return `window.localStorage.setItem('${STORAGE_KEY}', ${JSON.stringify(JSON.stringify(events))})` +} + +function organizerSeed(): StoredEvent { + return { + eventToken: fullEvent.eventToken, + organizerToken, + title: fullEvent.title, + dateTime: fullEvent.dateTime, + } +} + +test.describe('US1: Organizer cancels event with reason', () => { + test('organizer opens cancel bottom sheet, enters reason, confirms β€” event shows as cancelled on reload', async ({ + page, + network, + }) => { + let cancelled = false + network.use( + http.get('*/api/events/:token', () => { + if (cancelled) { + return HttpResponse.json({ + ...fullEvent, + cancelled: true, + cancellationReason: 'Venue closed', + }) + } + return HttpResponse.json(fullEvent) + }), + http.patch('*/api/events/:token', ({ request }) => { + const url = new URL(request.url) + const token = url.searchParams.get('organizerToken') + if (token === organizerToken) { + cancelled = true + return new HttpResponse(null, { status: 204 }) + } + return HttpResponse.json( + { type: 'urn:problem-type:invalid-organizer-token', title: 'Forbidden', status: 403 }, + { status: 403 }, + ) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + // Cancel button visible for organizer + const cancelBtn = page.getByRole('button', { name: /Cancel event/i }) + await expect(cancelBtn).toBeVisible() + + // Open cancel bottom sheet + await cancelBtn.click() + + // Fill in reason + const reasonField = page.getByLabel(/reason/i) + await expect(reasonField).toBeVisible() + await reasonField.fill('Venue closed') + + // Confirm cancellation + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Event should show as cancelled + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + await expect(page.getByText('Venue closed')).toBeVisible() + + // Cancel button should be gone + await expect(cancelBtn).not.toBeVisible() + }) +}) + +test.describe('US1: Organizer cancels event without reason', () => { + test('organizer cancels without reason β€” event shows as cancelled', async ({ + page, + network, + }) => { + let cancelled = false + network.use( + http.get('*/api/events/:token', () => { + if (cancelled) { + return HttpResponse.json({ + ...fullEvent, + cancelled: true, + cancellationReason: null, + }) + } + return HttpResponse.json(fullEvent) + }), + http.patch('*/api/events/:token', ({ request }) => { + const url = new URL(request.url) + const token = url.searchParams.get('organizerToken') + if (token === organizerToken) { + cancelled = true + return new HttpResponse(null, { status: 204 }) + } + return HttpResponse.json({}, { status: 403 }) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + await page.getByRole('button', { name: /Cancel event/i }).click() + + // Don't fill in reason, just confirm + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Event should show as cancelled without reason text + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + }) +}) + +test.describe('US1: Cancel API failure', () => { + test('cancel API fails β€” error displayed in bottom sheet, button re-enabled for retry', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(fullEvent)), + http.patch('*/api/events/:token', () => { + return HttpResponse.json( + { + type: 'about:blank', + title: 'Internal Server Error', + status: 500, + detail: 'Something went wrong', + }, + { status: 500, headers: { 'Content-Type': 'application/problem+json' } }, + ) + }), + ) + await page.addInitScript(seedEvents([organizerSeed()])) + await page.goto(`/events/${fullEvent.eventToken}`) + + await page.getByRole('button', { name: /Cancel event/i }).click() + await page.getByRole('button', { name: /Confirm cancellation/i }).click() + + // Error message in bottom sheet + await expect(page.getByText(/Could not cancel event/i)).toBeVisible() + + // Confirm button should be re-enabled + await expect(page.getByRole('button', { name: /Confirm cancellation/i })).toBeEnabled() + }) +}) diff --git a/frontend/e2e/cancelled-event-visitor.spec.ts b/frontend/e2e/cancelled-event-visitor.spec.ts new file mode 100644 index 0000000..83223a1 --- /dev/null +++ b/frontend/e2e/cancelled-event-visitor.spec.ts @@ -0,0 +1,74 @@ +import { http, HttpResponse } from 'msw' +import { test, expect } from './msw-setup' + +const cancelledEventWithReason = { + eventToken: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + title: 'Summer BBQ', + description: 'Bring your own drinks!', + dateTime: '2026-03-15T20:00:00+01:00', + timezone: 'Europe/Berlin', + location: 'Central Park, NYC', + attendeeCount: 12, + cancelled: true, + cancellationReason: 'Venue no longer available', +} + +const cancelledEventWithoutReason = { + ...cancelledEventWithReason, + cancellationReason: null, +} + +test.describe('US2: Visitor sees cancelled event with reason', () => { + test('visitor sees red banner with cancellation reason on cancelled event', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)), + ) + + await page.goto(`/events/${cancelledEventWithReason.eventToken}`) + + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + await expect(page.getByText('Venue no longer available')).toBeVisible() + }) +}) + +test.describe('US2: Visitor sees cancelled event without reason', () => { + test('visitor sees red banner without reason when no reason was provided', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithoutReason)), + ) + + await page.goto(`/events/${cancelledEventWithoutReason.eventToken}`) + + await expect(page.getByText(/This event has been cancelled/i)).toBeVisible() + // No reason text shown + await expect(page.getByText('Venue no longer available')).not.toBeVisible() + }) +}) + +test.describe('US2: RSVP buttons hidden on cancelled event', () => { + test('RSVP buttons hidden on cancelled event, other details remain visible', async ({ + page, + network, + }) => { + network.use( + http.get('*/api/events/:token', () => HttpResponse.json(cancelledEventWithReason)), + ) + + await page.goto(`/events/${cancelledEventWithReason.eventToken}`) + + // Event details are still visible + await expect(page.getByRole('heading', { name: 'Summer BBQ' })).toBeVisible() + await expect(page.getByText('Bring your own drinks!')).toBeVisible() + await expect(page.getByText('Central Park, NYC')).toBeVisible() + await expect(page.getByText('12 going')).toBeVisible() + + // RSVP bar is NOT visible + await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible() + }) +}) diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 224fd1c..769e874 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -18,6 +18,14 @@ --color-card: #ffffff; --color-dark-base: #1B1730; + /* Danger / destructive actions */ + --color-danger: #fca5a5; + --color-danger-bg: rgba(220, 38, 38, 0.15); + --color-danger-bg-hover: rgba(220, 38, 38, 0.25); + --color-danger-bg-strong: rgba(220, 38, 38, 0.2); + --color-danger-border: rgba(220, 38, 38, 0.3); + --color-danger-border-strong: rgba(220, 38, 38, 0.4); + /* Glass system */ --color-glass: rgba(255, 255, 255, 0.1); --color-glass-strong: rgba(255, 255, 255, 0.15); diff --git a/frontend/src/views/EventDetailView.vue b/frontend/src/views/EventDetailView.vue index 4f0a0ee..2a342c6 100644 --- a/frontend/src/views/EventDetailView.vue +++ b/frontend/src/views/EventDetailView.vue @@ -25,6 +25,12 @@
+ + +

{{ event.title }}

@@ -70,14 +76,49 @@
- + +
+ +
+ + + +

Cancel event

+
+
+ +