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..8a94dcc 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,6 +8,7 @@ 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.adapter.in.web.model.PatchEventRequest; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.InvalidTimezoneException; import de.fete.domain.model.CreateEventCommand; @@ -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 @@ -97,10 +102,23 @@ public class EventController implements EventsApi { response.setLocation(event.getLocation()); response.setAttendeeCount( (int) countAttendeesByEventUseCase.countByEvent(evtToken)); + response.setCancelled(event.isCancelled()); + response.setCancellationReason(event.getCancellationReason()); 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) { 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..bd16dcd 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,5 +1,7 @@ package de.fete.adapter.in.web; +import de.fete.application.service.EventAlreadyCancelledException; +import de.fete.application.service.EventCancelledException; import de.fete.application.service.EventExpiredException; import de.fete.application.service.EventNotFoundException; import de.fete.application.service.ExpiryDateBeforeEventException; @@ -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/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..358f10f 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 @@ -48,6 +48,8 @@ public class EventPersistenceAdapter implements EventRepository { entity.setLocation(event.getLocation()); entity.setExpiryDate(event.getExpiryDate()); entity.setCreatedAt(event.getCreatedAt()); + entity.setCancelled(event.isCancelled()); + entity.setCancellationReason(event.getCancellationReason()); return entity; } @@ -63,6 +65,8 @@ public class EventPersistenceAdapter implements EventRepository { event.setLocation(entity.getLocation()); event.setExpiryDate(entity.getExpiryDate()); event.setCreatedAt(entity.getCreatedAt()); + event.setCancelled(entity.isCancelled()); + event.setCancellationReason(entity.getCancellationReason()); return event; } } diff --git a/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java b/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java new file mode 100644 index 0000000..72f1ec4 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventAlreadyCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +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/EventCancelledException.java b/backend/src/main/java/de/fete/application/service/EventCancelledException.java new file mode 100644 index 0000000..14dbe68 --- /dev/null +++ b/backend/src/main/java/de/fete/application/service/EventCancelledException.java @@ -0,0 +1,12 @@ +package de.fete.application.service; + +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/EventService.java b/backend/src/main/java/de/fete/application/service/EventService.java index 70d226d..f8fbcec 100644 --- a/backend/src/main/java/de/fete/application/service/EventService.java +++ b/backend/src/main/java/de/fete/application/service/EventService.java @@ -6,16 +6,18 @@ 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; @@ -50,4 +52,29 @@ 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.getOrganizerToken().equals(organizerToken)) { + throw new InvalidOrganizerTokenException(); + } + + if (event.isCancelled()) { + throw new EventAlreadyCancelledException(eventToken.value()); + } + + event.setCancelled(true); + event.setCancellationReason(reason); + eventRepository.save(event); + } } 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..4e9e189 100644 --- a/backend/src/main/java/de/fete/application/service/RsvpService.java +++ b/backend/src/main/java/de/fete/application/service/RsvpService.java @@ -42,6 +42,10 @@ public class RsvpService Event event = eventRepository.findByEventToken(eventToken) .orElseThrow(() -> new EventNotFoundException(eventToken.value())); + if (event.isCancelled()) { + throw new EventCancelledException(eventToken.value()); + } + if (!event.getExpiryDate().isAfter(LocalDate.now(clock))) { throw new EventExpiredException(eventToken.value()); } 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..3e84a92 100644 --- a/backend/src/main/java/de/fete/domain/model/Event.java +++ b/backend/src/main/java/de/fete/domain/model/Event.java @@ -17,6 +17,8 @@ public class Event { private String location; private LocalDate expiryDate; private OffsetDateTime createdAt; + private boolean cancelled; + private String cancellationReason; /** Returns the internal database ID. */ public Long getId() { @@ -117,4 +119,24 @@ public class Event { public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + /** Returns whether the event has been cancelled. */ + public boolean isCancelled() { + return cancelled; + } + + /** Sets the cancelled flag. */ + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + /** Returns the cancellation reason, if any. */ + 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/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/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/application/service/EventServiceCancelTest.java b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java new file mode 100644 index 0000000..6fa6c43 --- /dev/null +++ b/backend/src/test/java/de/fete/application/service/EventServiceCancelTest.java @@ -0,0 +1,138 @@ +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.EventAlreadyCancelledException; +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(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(false); + + 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().isCancelled()).isTrue(); + assertThat(captor.getValue().getCancellationReason()).isEqualTo("Venue unavailable"); + } + + @Test + void cancelEventWithNullReason() { + EventToken eventToken = EventToken.generate(); + OrganizerToken organizerToken = OrganizerToken.generate(); + var event = new Event(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(false); + + 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().isCancelled()).isTrue(); + assertThat(captor.getValue().getCancellationReason()).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(); + event.setEventToken(eventToken); + event.setOrganizerToken(correctToken); + + 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(); + event.setEventToken(eventToken); + event.setOrganizerToken(organizerToken); + event.setCancelled(true); + + 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/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

+
+
+ +