Compare commits
10 Commits
0.8.0
...
60649ae4de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60649ae4de | ||
| e90aefae15 | |||
|
|
622932418d | ||
| a1855ff8d6 | |||
| 4bfaee685c | |||
| 2a6a658df9 | |||
| 37d378ca59 | |||
| 0441ca0c33 | |||
|
|
e6711b33d4 | ||
| 6b3a06a72c |
@@ -55,6 +55,8 @@ The following skills are available and should be used for their respective purpo
|
|||||||
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
- PostgreSQL (JPA via Spring Data, Liquibase migrations) (007-view-event)
|
||||||
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
- TypeScript 5.9 (frontend only) + Vue 3, Vue Router 5 (existing — no additions) (010-event-list-grouping)
|
||||||
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
- localStorage via `useEventStorage.ts` composable (existing — no changes) (010-event-list-grouping)
|
||||||
|
- Java 25, Spring Boot 3.5.x + Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query) (013-auto-delete-expired)
|
||||||
|
- PostgreSQL (existing, Liquibase migrations) (013-auto-delete-expired)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
- 007-view-event: Added Java 25 (backend), TypeScript 5.9 (frontend) + Spring Boot 3.5.x, Vue 3, Vue Router 5, openapi-fetch, openapi-typescript
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,6 +1,25 @@
|
|||||||
# fete
|
<p align="center">
|
||||||
|
<img src="frontend/public/og-image.png" alt="fete" width="100%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
A privacy-focused, self-hostable web app for event announcements and RSVPs. An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
<p align="center">
|
||||||
|
<strong>Privacy-focused, self-hostable event announcements and RSVPs.</strong><br>
|
||||||
|
An alternative to Facebook Events or Telegram groups — reduced to the essentials.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/01-create-event.png" alt="Create Event" width="230" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/02-event-detail.png" alt="Event Detail" width="230" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/03-rsvp.png" alt="RSVP" width="230" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Create events · Share with guests · Collect RSVPs</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
|
|||||||
@@ -7,4 +7,8 @@
|
|||||||
<Match>
|
<Match>
|
||||||
<Package name="de.fete.adapter.in.web.model"/>
|
<Package name="de.fete.adapter.in.web.model"/>
|
||||||
</Match>
|
</Match>
|
||||||
|
<!-- Constructor-injected Spring beans storing interfaces/proxies are not a real exposure risk -->
|
||||||
|
<Match>
|
||||||
|
<Bug pattern="EI_EXPOSE_REP2"/>
|
||||||
|
</Match>
|
||||||
</FindBugsFilter>
|
</FindBugsFilter>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package de.fete;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/** Spring Boot entry point for the fete application. */
|
/** Spring Boot entry point for the fete application. */
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
public class FeteApplication {
|
public class FeteApplication {
|
||||||
|
|
||||||
/** Starts the application. */
|
/** Starts the application. */
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ import de.fete.domain.port.in.CreateEventUseCase;
|
|||||||
import de.fete.domain.port.in.CreateRsvpUseCase;
|
import de.fete.domain.port.in.CreateRsvpUseCase;
|
||||||
import de.fete.domain.port.in.GetAttendeesUseCase;
|
import de.fete.domain.port.in.GetAttendeesUseCase;
|
||||||
import de.fete.domain.port.in.GetEventUseCase;
|
import de.fete.domain.port.in.GetEventUseCase;
|
||||||
import java.time.Clock;
|
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -39,22 +37,19 @@ public class EventController implements EventsApi {
|
|||||||
private final CreateRsvpUseCase createRsvpUseCase;
|
private final CreateRsvpUseCase createRsvpUseCase;
|
||||||
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
private final CountAttendeesByEventUseCase countAttendeesByEventUseCase;
|
||||||
private final GetAttendeesUseCase getAttendeesUseCase;
|
private final GetAttendeesUseCase getAttendeesUseCase;
|
||||||
private final Clock clock;
|
|
||||||
|
|
||||||
/** Creates a new controller with the given use cases and clock. */
|
/** Creates a new controller with the given use cases. */
|
||||||
public EventController(
|
public EventController(
|
||||||
CreateEventUseCase createEventUseCase,
|
CreateEventUseCase createEventUseCase,
|
||||||
GetEventUseCase getEventUseCase,
|
GetEventUseCase getEventUseCase,
|
||||||
CreateRsvpUseCase createRsvpUseCase,
|
CreateRsvpUseCase createRsvpUseCase,
|
||||||
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
CountAttendeesByEventUseCase countAttendeesByEventUseCase,
|
||||||
GetAttendeesUseCase getAttendeesUseCase,
|
GetAttendeesUseCase getAttendeesUseCase) {
|
||||||
Clock clock) {
|
|
||||||
this.createEventUseCase = createEventUseCase;
|
this.createEventUseCase = createEventUseCase;
|
||||||
this.getEventUseCase = getEventUseCase;
|
this.getEventUseCase = getEventUseCase;
|
||||||
this.createRsvpUseCase = createRsvpUseCase;
|
this.createRsvpUseCase = createRsvpUseCase;
|
||||||
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
this.countAttendeesByEventUseCase = countAttendeesByEventUseCase;
|
||||||
this.getAttendeesUseCase = getAttendeesUseCase;
|
this.getAttendeesUseCase = getAttendeesUseCase;
|
||||||
this.clock = clock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -67,8 +62,7 @@ public class EventController implements EventsApi {
|
|||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getDateTime(),
|
request.getDateTime(),
|
||||||
zoneId,
|
zoneId,
|
||||||
request.getLocation(),
|
request.getLocation()
|
||||||
request.getExpiryDate()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event event = createEventUseCase.createEvent(command);
|
Event event = createEventUseCase.createEvent(command);
|
||||||
@@ -79,7 +73,6 @@ public class EventController implements EventsApi {
|
|||||||
response.setTitle(event.getTitle());
|
response.setTitle(event.getTitle());
|
||||||
response.setDateTime(event.getDateTime());
|
response.setDateTime(event.getDateTime());
|
||||||
response.setTimezone(event.getTimezone().getId());
|
response.setTimezone(event.getTimezone().getId());
|
||||||
response.setExpiryDate(event.getExpiryDate());
|
|
||||||
|
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
}
|
}
|
||||||
@@ -99,8 +92,6 @@ public class EventController implements EventsApi {
|
|||||||
response.setLocation(event.getLocation());
|
response.setLocation(event.getLocation());
|
||||||
response.setAttendeeCount(
|
response.setAttendeeCount(
|
||||||
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
(int) countAttendeesByEventUseCase.countByEvent(eventToken));
|
||||||
response.setExpired(
|
|
||||||
event.getExpiryDate().isBefore(LocalDate.now(clock)));
|
|
||||||
|
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ package de.fete.adapter.out.persistence;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
/** Spring Data JPA repository for event entities. */
|
/** Spring Data JPA repository for event entities. */
|
||||||
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
public interface EventJpaRepository extends JpaRepository<EventJpaEntity, Long> {
|
||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
Optional<EventJpaEntity> findByEventToken(UUID eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is before today. Returns the number of deleted rows. */
|
||||||
|
@Modifying
|
||||||
|
@Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public class EventPersistenceAdapter implements EventRepository {
|
|||||||
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
return jpaRepository.findByEventToken(eventToken.value()).map(this::toDomain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int deleteExpired() {
|
||||||
|
return jpaRepository.deleteExpired();
|
||||||
|
}
|
||||||
|
|
||||||
private EventJpaEntity toEntity(Event event) {
|
private EventJpaEntity toEntity(Event event) {
|
||||||
var entity = new EventJpaEntity();
|
var entity = new EventJpaEntity();
|
||||||
entity.setId(event.getId());
|
entity.setId(event.getId());
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
public class EventService implements CreateEventUseCase, GetEventUseCase {
|
||||||
|
|
||||||
|
private static final int EXPIRY_DAYS_AFTER_EVENT = 7;
|
||||||
|
|
||||||
private final EventRepository eventRepository;
|
private final EventRepository eventRepository;
|
||||||
private final Clock clock;
|
private final Clock clock;
|
||||||
|
|
||||||
@@ -28,13 +30,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Event createEvent(CreateEventCommand command) {
|
public Event createEvent(CreateEventCommand command) {
|
||||||
if (!command.expiryDate().isAfter(LocalDate.now(clock))) {
|
LocalDate expiryDate = command.dateTime().toLocalDate().plusDays(EXPIRY_DAYS_AFTER_EVENT);
|
||||||
throw new ExpiryDateInPastException(command.expiryDate());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!command.expiryDate().isAfter(command.dateTime().toLocalDate())) {
|
|
||||||
throw new ExpiryDateBeforeEventException(command.expiryDate(), command.dateTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
var event = new Event();
|
var event = new Event();
|
||||||
event.setEventToken(EventToken.generate());
|
event.setEventToken(EventToken.generate());
|
||||||
@@ -44,7 +40,7 @@ public class EventService implements CreateEventUseCase, GetEventUseCase {
|
|||||||
event.setDateTime(command.dateTime());
|
event.setDateTime(command.dateTime());
|
||||||
event.setTimezone(command.timezone());
|
event.setTimezone(command.timezone());
|
||||||
event.setLocation(command.location());
|
event.setLocation(command.location());
|
||||||
event.setExpiryDate(command.expiryDate());
|
event.setExpiryDate(expiryDate);
|
||||||
event.setCreatedAt(OffsetDateTime.now(clock));
|
event.setCreatedAt(OffsetDateTime.now(clock));
|
||||||
|
|
||||||
return eventRepository.save(event);
|
return eventRepository.save(event);
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package de.fete.application.service;
|
||||||
|
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/** Scheduled job that deletes events whose expiry date is in the past. */
|
||||||
|
@Component
|
||||||
|
public class ExpiredEventCleanupJob {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpiredEventCleanupJob.class);
|
||||||
|
|
||||||
|
private final EventRepository eventRepository;
|
||||||
|
|
||||||
|
/** Creates a new cleanup job with the given event repository. */
|
||||||
|
public ExpiredEventCleanupJob(EventRepository eventRepository) {
|
||||||
|
this.eventRepository = eventRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs daily at 03:00 and deletes all expired events. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void deleteExpiredEvents() {
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
log.info("Expired event cleanup: deleted {} event(s)", deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,5 @@ public record CreateEventCommand(
|
|||||||
String description,
|
String description,
|
||||||
OffsetDateTime dateTime,
|
OffsetDateTime dateTime,
|
||||||
ZoneId timezone,
|
ZoneId timezone,
|
||||||
String location,
|
String location
|
||||||
LocalDate expiryDate
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -12,4 +12,7 @@ public interface EventRepository {
|
|||||||
|
|
||||||
/** Finds an event by its public event token. */
|
/** Finds an event by its public event token. */
|
||||||
Optional<Event> findByEventToken(EventToken eventToken);
|
Optional<Event> findByEventToken(EventToken eventToken);
|
||||||
|
|
||||||
|
/** Deletes all events whose expiry date is in the past. Returns the number of deleted events. */
|
||||||
|
int deleteExpired();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
title:
|
title:
|
||||||
type: string
|
type: string
|
||||||
@@ -181,11 +180,6 @@ components:
|
|||||||
location:
|
location:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 500
|
maxLength: 500
|
||||||
expiryDate:
|
|
||||||
type: string
|
|
||||||
format: date
|
|
||||||
description: Date after which event data is deleted. Must be in the future.
|
|
||||||
example: "2026-06-15"
|
|
||||||
|
|
||||||
CreateEventResponse:
|
CreateEventResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -195,7 +189,6 @@ components:
|
|||||||
- title
|
- title
|
||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- expiryDate
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -218,10 +211,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: IANA timezone of the organizer
|
description: IANA timezone of the organizer
|
||||||
example: "Europe/Berlin"
|
example: "Europe/Berlin"
|
||||||
expiryDate:
|
|
||||||
type: string
|
|
||||||
format: date
|
|
||||||
example: "2026-06-15"
|
|
||||||
|
|
||||||
GetEventResponse:
|
GetEventResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -231,7 +220,6 @@ components:
|
|||||||
- dateTime
|
- dateTime
|
||||||
- timezone
|
- timezone
|
||||||
- attendeeCount
|
- attendeeCount
|
||||||
- expired
|
|
||||||
properties:
|
properties:
|
||||||
eventToken:
|
eventToken:
|
||||||
type: string
|
type: string
|
||||||
@@ -264,10 +252,6 @@ components:
|
|||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of confirmed attendees (attending=true)
|
description: Number of confirmed attendees (attending=true)
|
||||||
example: 12
|
example: 12
|
||||||
expired:
|
|
||||||
type: boolean
|
|
||||||
description: Whether the event's expiry date has passed
|
|
||||||
example: false
|
|
||||||
|
|
||||||
CreateRsvpRequest:
|
CreateRsvpRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ class EventControllerIntegrationTest {
|
|||||||
.description("Come celebrate!")
|
.description("Come celebrate!")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin")
|
.timezone("Europe/Berlin")
|
||||||
.location("Berlin")
|
.location("Berlin");
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -67,7 +66,6 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
.andExpect(jsonPath("$.title").value("Birthday Party"))
|
||||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
.andExpect(jsonPath("$.dateTime").isNotEmpty())
|
||||||
.andExpect(jsonPath("$.expiryDate").isNotEmpty())
|
|
||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
var response = objectMapper.readValue(
|
var response = objectMapper.readValue(
|
||||||
@@ -79,7 +77,7 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
assertThat(persisted.getDescription()).isEqualTo("Come celebrate!");
|
||||||
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
assertThat(persisted.getTimezone()).isEqualTo("Europe/Berlin");
|
||||||
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
assertThat(persisted.getLocation()).isEqualTo("Berlin");
|
||||||
assertThat(persisted.getExpiryDate()).isEqualTo(request.getExpiryDate());
|
assertThat(persisted.getExpiryDate()).isEqualTo(LocalDate.of(2026, 6, 22));
|
||||||
assertThat(persisted.getDateTime().toInstant())
|
assertThat(persisted.getDateTime().toInstant())
|
||||||
.isEqualTo(request.getDateTime().toInstant());
|
.isEqualTo(request.getDateTime().toInstant());
|
||||||
assertThat(persisted.getOrganizerToken()).isNotNull();
|
assertThat(persisted.getOrganizerToken()).isNotNull();
|
||||||
@@ -91,8 +89,7 @@ class EventControllerIntegrationTest {
|
|||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("Minimal Event")
|
.title("Minimal Event")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("UTC")
|
.timezone("UTC");
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/events")
|
var result = mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -119,8 +116,7 @@ class EventControllerIntegrationTest {
|
|||||||
|
|
||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin")
|
.timezone("Europe/Berlin");
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -139,26 +135,6 @@ class EventControllerIntegrationTest {
|
|||||||
|
|
||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("No Date")
|
.title("No Date")
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.fieldErrors").isArray());
|
|
||||||
|
|
||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventMissingExpiryDateReturns400() throws Exception {
|
|
||||||
long countBefore = jpaRepository.count();
|
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("No Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin");
|
.timezone("Europe/Berlin");
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
@@ -171,93 +147,12 @@ class EventControllerIntegrationTest {
|
|||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventExpiryDateInPastReturns400() throws Exception {
|
|
||||||
long countBefore = jpaRepository.count();
|
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("Past Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.of(2025, 1, 1));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
|
||||||
|
|
||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventExpiryDateTodayReturns400() throws Exception {
|
|
||||||
long countBefore = jpaRepository.count();
|
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("Today Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.now());
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past"));
|
|
||||||
|
|
||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventExpiryDateBeforeEventDateReturns400() throws Exception {
|
|
||||||
long countBefore = jpaRepository.count();
|
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("Bad Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.of(2026, 6, 10));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
|
||||||
|
|
||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void createEventExpiryDateSameAsEventDateReturns400() throws Exception {
|
|
||||||
long countBefore = jpaRepository.count();
|
|
||||||
|
|
||||||
var request = new CreateEventRequest()
|
|
||||||
.title("Same Day Expiry")
|
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
|
||||||
.timezone("Europe/Berlin")
|
|
||||||
.expiryDate(LocalDate.of(2026, 6, 15));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
|
|
||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-before-event"));
|
|
||||||
|
|
||||||
assertThat(jpaRepository.count()).isEqualTo(countBefore);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void errorResponseContentTypeIsProblemJson() throws Exception {
|
void errorResponseContentTypeIsProblemJson() throws Exception {
|
||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("")
|
.title("")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Europe/Berlin")
|
.timezone("Europe/Berlin");
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -273,8 +168,7 @@ class EventControllerIntegrationTest {
|
|||||||
var request = new CreateEventRequest()
|
var request = new CreateEventRequest()
|
||||||
.title("Bad TZ")
|
.title("Bad TZ")
|
||||||
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
.dateTime(OffsetDateTime.of(2026, 6, 15, 20, 0, 0, 0, ZoneOffset.ofHours(2)))
|
||||||
.timezone("Not/A/Zone")
|
.timezone("Not/A/Zone");
|
||||||
.expiryDate(LocalDate.of(2026, 6, 16));
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/events")
|
mockMvc.perform(post("/api/events")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
@@ -302,7 +196,6 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
.andExpect(jsonPath("$.timezone").value("Europe/Berlin"))
|
||||||
.andExpect(jsonPath("$.location").value("Central Park"))
|
.andExpect(jsonPath("$.location").value("Central Park"))
|
||||||
.andExpect(jsonPath("$.attendeeCount").value(0))
|
.andExpect(jsonPath("$.attendeeCount").value(0))
|
||||||
.andExpect(jsonPath("$.expired").value(false))
|
|
||||||
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
.andExpect(jsonPath("$.dateTime").isNotEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,18 +220,6 @@ class EventControllerIntegrationTest {
|
|||||||
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
.andExpect(jsonPath("$.type").value("urn:problem-type:event-not-found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void getExpiredEventReturnsExpiredTrue() throws Exception {
|
|
||||||
EventJpaEntity entity = seedEvent(
|
|
||||||
"Past Event", "It happened", "Europe/Berlin",
|
|
||||||
"Old Venue", LocalDate.now().minusDays(1));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/events/" + entity.getEventToken()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.title").value("Past Event"))
|
|
||||||
.andExpect(jsonPath("$.expired").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- RSVP tests ---
|
// --- RSVP tests ---
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package de.fete.adapter.out.persistence;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import de.fete.TestcontainersConfig;
|
||||||
|
import de.fete.domain.model.Event;
|
||||||
|
import de.fete.domain.model.EventToken;
|
||||||
|
import de.fete.domain.model.OrganizerToken;
|
||||||
|
import de.fete.domain.port.out.EventRepository;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Import(TestcontainersConfig.class)
|
||||||
|
@Transactional
|
||||||
|
class EventPersistenceAdapterIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private EventRepository eventRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredRemovesExpiredEvents() {
|
||||||
|
Event expired = buildEvent("Expired Party", LocalDate.now().minusDays(1));
|
||||||
|
eventRepository.save(expired);
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsNonExpiredEvents() {
|
||||||
|
Event future = buildEvent("Future Party", LocalDate.now().plusDays(30));
|
||||||
|
Event saved = eventRepository.save(future);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredKeepsEventsExpiringToday() {
|
||||||
|
Event today = buildEvent("Today Party", LocalDate.now());
|
||||||
|
Event saved = eventRepository.save(today);
|
||||||
|
|
||||||
|
eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(eventRepository.findByEventToken(saved.getEventToken())).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteExpiredReturnsZeroWhenNoneExpired() {
|
||||||
|
// Only save a future event
|
||||||
|
buildEvent("Future Only", LocalDate.now().plusDays(60));
|
||||||
|
|
||||||
|
int deleted = eventRepository.deleteExpired();
|
||||||
|
|
||||||
|
assertThat(deleted).isGreaterThanOrEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.fete.application.service;
|
package de.fete.application.service;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -53,8 +52,7 @@ class EventServiceTest {
|
|||||||
"Come celebrate!",
|
"Come celebrate!",
|
||||||
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
TODAY.plusDays(90).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
ZONE,
|
ZONE,
|
||||||
"Berlin",
|
"Berlin"
|
||||||
TODAY.plusDays(120)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
@@ -75,8 +73,7 @@ class EventServiceTest {
|
|||||||
|
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||||
TODAY.plusDays(11)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
eventService.createEvent(command);
|
eventService.createEvent(command);
|
||||||
@@ -87,86 +84,19 @@ class EventServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void expiryDateTodayThrowsException() {
|
void expiryDateIsEventDatePlusSevenDays() {
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
|
||||||
TODAY
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateInPastException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateInPastThrowsException() {
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
|
||||||
TODAY.minusDays(5)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateInPastException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateTomorrowSucceeds() {
|
|
||||||
when(eventRepository.save(any(Event.class)))
|
when(eventRepository.save(any(Event.class)))
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
|
var eventDate = TODAY.plusDays(10);
|
||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
TODAY.plusDays(1).atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null,
|
eventDate.atStartOfDay(ZONE).toOffsetDateTime(), ZONE, null
|
||||||
TODAY.plusDays(2)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(2));
|
assertThat(result.getExpiryDate()).isEqualTo(eventDate.plusDays(7));
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateSameAsEventDateThrowsException() {
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
|
||||||
ZONE, null,
|
|
||||||
TODAY.plusDays(10)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateBeforeEventDateThrowsException() {
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
|
||||||
ZONE, null,
|
|
||||||
TODAY.plusDays(5)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> eventService.createEvent(command))
|
|
||||||
.isInstanceOf(ExpiryDateBeforeEventException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void expiryDateDayAfterEventDateSucceeds() {
|
|
||||||
when(eventRepository.save(any(Event.class)))
|
|
||||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
|
||||||
|
|
||||||
var command = new CreateEventCommand(
|
|
||||||
"Test", null,
|
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
|
||||||
ZONE, null,
|
|
||||||
TODAY.plusDays(11)
|
|
||||||
);
|
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
|
||||||
|
|
||||||
assertThat(result.getExpiryDate()).isEqualTo(TODAY.plusDays(11));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- GetEventUseCase tests (T004) ---
|
// --- GetEventUseCase tests (T004) ---
|
||||||
@@ -207,8 +137,7 @@ class EventServiceTest {
|
|||||||
var command = new CreateEventCommand(
|
var command = new CreateEventCommand(
|
||||||
"Test", null,
|
"Test", null,
|
||||||
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
TODAY.plusDays(10).atStartOfDay(ZONE).toOffsetDateTime(),
|
||||||
ZoneId.of("America/New_York"), null,
|
ZoneId.of("America/New_York"), null
|
||||||
TODAY.plusDays(11)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Event result = eventService.createEvent(command);
|
Event result = eventService.createEvent(command);
|
||||||
|
|||||||
BIN
docs/screenshots/01-create-event.png
Normal file
BIN
docs/screenshots/01-create-event.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
BIN
docs/screenshots/02-event-detail.png
Normal file
BIN
docs/screenshots/02-event-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
BIN
docs/screenshots/03-rsvp.png
Normal file
BIN
docs/screenshots/03-rsvp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 309 KiB |
@@ -9,7 +9,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
|
|
||||||
await expect(page.getByText('Title is required.')).toBeVisible()
|
await expect(page.getByText('Title is required.')).toBeVisible()
|
||||||
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
await expect(page.getByText('Date and time are required.')).toBeVisible()
|
||||||
await expect(page.getByText('Expiry date is required.')).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('creates an event and redirects to event detail page', async ({ page }) => {
|
test('creates an event and redirects to event detail page', async ({ page }) => {
|
||||||
@@ -19,7 +18,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
await page.getByLabel(/description/i).fill('Bring your own drinks')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/location/i).fill('Central Park')
|
await page.getByLabel(/location/i).fill('Central Park')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
@@ -31,7 +29,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
|
|
||||||
await page.getByLabel(/title/i).fill('Summer BBQ')
|
await page.getByLabel(/title/i).fill('Summer BBQ')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
await expect(page).toHaveURL(/\/events\/.+/)
|
await expect(page).toHaveURL(/\/events\/.+/)
|
||||||
@@ -59,7 +56,6 @@ test.describe('US-1: Create an event', () => {
|
|||||||
await page.goto('/create')
|
await page.goto('/create')
|
||||||
await page.getByLabel(/title/i).fill('Test')
|
await page.getByLabel(/title/i).fill('Test')
|
||||||
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
await page.getByLabel(/date/i).first().fill('2026-04-15T18:00')
|
||||||
await page.getByLabel(/expiry/i).fill('2026-06-15')
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create event/i }).click()
|
await page.getByRole('button', { name: /create event/i }).click()
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
|||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
location: 'Central Park, NYC',
|
location: 'Central Park, NYC',
|
||||||
attendeeCount: 12,
|
attendeeCount: 12,
|
||||||
expired: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('US1: RSVP submission flow', () => {
|
test.describe('US1: RSVP submission flow', () => {
|
||||||
@@ -170,16 +169,4 @@ test.describe('US1: RSVP submission flow', () => {
|
|||||||
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
await expect(page.getByText("You're attending!")).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('does not show RSVP bar on expired event', async ({ page, network }) => {
|
|
||||||
network.use(
|
|
||||||
http.get('*/api/events/:token', () => {
|
|
||||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
||||||
|
|
||||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
|
||||||
await expect(page.getByRole('button', { name: "I'm attending" })).not.toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const fullEvent = {
|
|||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
location: 'Central Park, NYC',
|
location: 'Central Park, NYC',
|
||||||
attendeeCount: 12,
|
attendeeCount: 12,
|
||||||
expired: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('US-1: View event details', () => {
|
test.describe('US-1: View event details', () => {
|
||||||
@@ -52,20 +51,6 @@ test.describe('US-1: View event details', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test.describe('US-2: View expired event', () => {
|
|
||||||
test('shows "event has ended" banner for expired event', async ({ page, network }) => {
|
|
||||||
network.use(
|
|
||||||
http.get('*/api/events/:token', () => {
|
|
||||||
return HttpResponse.json({ ...fullEvent, expired: true })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/events/${fullEvent.eventToken}`)
|
|
||||||
|
|
||||||
await expect(page.getByText('This event has ended.')).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test.describe('US-4: Event not found', () => {
|
test.describe('US-4: Event not found', () => {
|
||||||
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
test('shows "event not found" for unknown token', async ({ page, network }) => {
|
||||||
network.use(
|
network.use(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const futureEvent1: StoredEvent = {
|
|||||||
eventToken: 'future-aaa',
|
eventToken: 'future-aaa',
|
||||||
title: 'Summer BBQ',
|
title: 'Summer BBQ',
|
||||||
dateTime: '2027-06-15T18:00:00Z',
|
dateTime: '2027-06-15T18:00:00Z',
|
||||||
expiryDate: '2027-06-16T00:00:00Z',
|
|
||||||
organizerToken: 'org-token-1',
|
organizerToken: 'org-token-1',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ const futureEvent2: StoredEvent = {
|
|||||||
eventToken: 'future-bbb',
|
eventToken: 'future-bbb',
|
||||||
title: 'Team Meeting',
|
title: 'Team Meeting',
|
||||||
dateTime: '2027-01-10T09:00:00Z',
|
dateTime: '2027-01-10T09:00:00Z',
|
||||||
expiryDate: '2027-01-11T00:00:00Z',
|
|
||||||
rsvpToken: 'rsvp-token-1',
|
rsvpToken: 'rsvp-token-1',
|
||||||
rsvpName: 'Alice',
|
rsvpName: 'Alice',
|
||||||
}
|
}
|
||||||
@@ -24,7 +22,6 @@ const pastEvent: StoredEvent = {
|
|||||||
eventToken: 'past-ccc',
|
eventToken: 'past-ccc',
|
||||||
title: 'New Year Party',
|
title: 'New Year Party',
|
||||||
dateTime: '2025-01-01T00:00:00Z',
|
dateTime: '2025-01-01T00:00:00Z',
|
||||||
expiryDate: '2025-01-02T00:00:00Z',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedEvents(events: StoredEvent[]): string {
|
function seedEvents(events: StoredEvent[]): string {
|
||||||
@@ -85,7 +82,6 @@ test.describe('US4: Past Events Appear Faded', () => {
|
|||||||
location: '',
|
location: '',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
attendeeCount: 0,
|
attendeeCount: 0,
|
||||||
expired: true,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -199,13 +195,11 @@ test.describe('Temporal Grouping: Section Headers', () => {
|
|||||||
eventToken: 'today-1',
|
eventToken: 'today-1',
|
||||||
title: 'Today Standup',
|
title: 'Today Standup',
|
||||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0, 0).toISOString(),
|
||||||
expiryDate: '',
|
|
||||||
}
|
}
|
||||||
const laterEvent: StoredEvent = {
|
const laterEvent: StoredEvent = {
|
||||||
eventToken: 'later-1',
|
eventToken: 'later-1',
|
||||||
title: 'Future Conference',
|
title: 'Future Conference',
|
||||||
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
dateTime: new Date(now.getFullYear() + 1, 0, 15, 10, 0, 0).toISOString(),
|
||||||
expiryDate: '',
|
|
||||||
}
|
}
|
||||||
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
await page.addInitScript(seedEvents([todayEvent, laterEvent, pastEvent]))
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -245,7 +239,6 @@ test.describe('Temporal Grouping: Section Headers', () => {
|
|||||||
eventToken: 'today-emph',
|
eventToken: 'today-emph',
|
||||||
title: 'Emphasis Test',
|
title: 'Emphasis Test',
|
||||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 20, 0, 0).toISOString(),
|
||||||
expiryDate: '',
|
|
||||||
}
|
}
|
||||||
await page.addInitScript(seedEvents([todayEvent]))
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -262,7 +255,6 @@ test.describe('Temporal Grouping: Date Subheaders', () => {
|
|||||||
eventToken: 'today-sub',
|
eventToken: 'today-sub',
|
||||||
title: 'No Subheader Test',
|
title: 'No Subheader Test',
|
||||||
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
dateTime: new Date(now.getFullYear(), now.getMonth(), now.getDate(), 19, 0, 0).toISOString(),
|
||||||
expiryDate: '',
|
|
||||||
}
|
}
|
||||||
await page.addInitScript(seedEvents([todayEvent]))
|
await page.addInitScript(seedEvents([todayEvent]))
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
@@ -355,7 +347,6 @@ test.describe('US1: View My Events', () => {
|
|||||||
location: '',
|
location: '',
|
||||||
timezone: 'UTC',
|
timezone: 'UTC',
|
||||||
attendeeCount: 0,
|
attendeeCount: 0,
|
||||||
expired: false,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
174
frontend/package-lock.json
generated
174
frontend/package-lock.json
generated
@@ -26,14 +26,14 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.51.0",
|
"eslint-plugin-oxlint": "~1.53.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"msw": "^2.12.10",
|
"msw": "^2.12.10",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "~1.51.0",
|
"oxlint": "~1.54.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.54.0.tgz",
|
||||||
"integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==",
|
"integrity": "sha512-khWlVXUa4CPvp4eXnj7/TUNeyiarwvTEmZggylPIPUCSWgPqUBFGElIBa9xKNCQt4pb+WSGArCNEAy22ekQQxg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1744,9 +1744,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-android-arm64": {
|
"node_modules/@oxlint/binding-android-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==",
|
"integrity": "sha512-p3qEVSDVmyducpI9ORTJNbaMyXfICidDXGaf3WwyDyiXPExyZdfc6UsPepGPxImlfFJs5kxOCPqP4ut12Ed9pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1761,9 +1761,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==",
|
"integrity": "sha512-tUQ+vn/AL2P2Rz6cl3gsFA+4pMBFMRR0e6AVePoezV9iGTSMZUXIf0YWHq203bwNDXcY04vSXDqPB6E1b9WULA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1778,9 +1778,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-darwin-x64": {
|
"node_modules/@oxlint/binding-darwin-x64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.54.0.tgz",
|
||||||
"integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==",
|
"integrity": "sha512-9tzH/OLGZCzVGEXW0D3I0OrQOOWciHYWV9mcxjqqQE6f1DMOXT23mWj5/OQtqhW3E1VfHf0uf2MzMbM2gboPlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1795,9 +1795,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.54.0.tgz",
|
||||||
"integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==",
|
"integrity": "sha512-XelGa+x7vC9epF5JzXWHaQtdxqcL5R+H2WcEkr+JDkfmY3dAXVLa+51qW3+MWt7CZanakaOyhraLzdM32pWFfg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1812,9 +1812,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.54.0.tgz",
|
||||||
"integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==",
|
"integrity": "sha512-t5FubAU899vMF+6rBtxDewkwSAeFn8OUCKb/+jJ1XqPU0FTR+RffnTfXafVE8WXw39eN4pzUK3rkxUWlcsFuaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1829,9 +1829,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.54.0.tgz",
|
||||||
"integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==",
|
"integrity": "sha512-Du6cwkoMRi9DAgITzQpzS1QaEqE+5BP3Tfgr1yPy3C034n6IHISCwf4KMB+wojg8FE/kYvrJDibT0ENdcR3jUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1846,9 +1846,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==",
|
"integrity": "sha512-R4PjJ1lfghsVBuAegW79jkvUWbN8P4F28KaKbM72dW6ZWTYMg6M0pkTilXFZ+q/mb8f0MEyR1rO16useoa6KTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1863,9 +1863,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==",
|
"integrity": "sha512-WBNFe1foIFg6ipgzjzJfDLn7C5nZpLr/UHlnlT7GI3scCD2xpJiJTGMTTpJqA8p0Q1l2NsEnAOj9PtreF0Tkzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1880,9 +1880,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==",
|
"integrity": "sha512-M+UAW76rZHHiYKKy4e7Te5MNikANiFhYWl0qYF1MTIhQczYIqWVDQ+SX0SzW8ipOB/oK3+enOvlvJuhMoA968Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1897,9 +1897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==",
|
"integrity": "sha512-sGassljr8TR5F1WCqOaacrp3mYi107MnjkqlSXOMHACps7U2bL4v7M3RY7P7NMcGkNhzRHF3VO5+XyPWhTVM6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1914,9 +1914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==",
|
"integrity": "sha512-mW5Z6XTO8QWtlUjlf6yjntarjnbrmGVSK/V6XYy2rjH8xUfv9pn9G1vO92DBBBi0eUwnVpcOY3hMkP851WZNWg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1931,9 +1931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==",
|
"integrity": "sha512-nfPmEGW9BfWEUD0PVXGaYa2RS4wVblofNih1gsyUoLSWSyliNisIWd7F+QXrDSL5SJ78BPZjDW6FainV8qRiTg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1948,9 +1948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.54.0.tgz",
|
||||||
"integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==",
|
"integrity": "sha512-gybxMQx4NN1T+pa8TLwgVS4u9H9Hxwm7Sl0qvnQJF2WRhNN1oTOCzkmmCBbW/29DYLeV99WU76zh7srCamK9yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1965,9 +1965,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.54.0.tgz",
|
||||||
"integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==",
|
"integrity": "sha512-EAXMh2w3pzSj/aiB67kXzcHIoKxAywC1i1IgxA1sLY7iffEaZowDTOJgirY7WxeR/WiiKwak89gPeh9wUdLnIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1982,9 +1982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.54.0.tgz",
|
||||||
"integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==",
|
"integrity": "sha512-Ih7CfITkbw86+LjgV5gDwNmNQlvz7X+51gdeLLUDwuA/9lojDxCmRQEzeMl44KStQlwzCuNIcgGMae0MllV5ww==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1999,9 +1999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==",
|
"integrity": "sha512-qGQ8KIEfdgwwJd09Nc0d5hzlHQAM0EMoshMzHG/nNlMAHXrY7RFyat6VbzjflifAFb7LPAw+Yw9AB8cfzR7L8g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2016,9 +2016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==",
|
"integrity": "sha512-GXos7N5oZIcIE8hlXgtUkI+y3frIqwiztx9p4+ZrpNgASbnIsxxy8bgLJqXE2SGCAdmtjBO1mdKhGTtVrb1jbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2033,9 +2033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.54.0.tgz",
|
||||||
"integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==",
|
"integrity": "sha512-DVxSELcI72lmcODuVpO8uXkwuYPiA0hKMhBwNa8fO4+sIVxFvxugf/z6qrL+4PM8RjGWjdl8O/QqzuRWj9b5bA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2947,9 +2947,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/eslint-plugin": {
|
"node_modules/@vitest/eslint-plugin": {
|
||||||
"version": "1.6.9",
|
"version": "1.6.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.10.tgz",
|
||||||
"integrity": "sha512-9WfPx1OwJ19QLCSRLkqVO7//1WcWnK3fE/3fJhKMAmDe8+9G4rB47xCNIIeCq3FdEzkIoLTfDlwDlPBaUTMhow==",
|
"integrity": "sha512-/cOf+mTu4HBJIYHTETo8/OFCSZv3T2p+KfGnouzKfjK063cWLZp0TzvK7EU5B3eFG7ypUNtw6l+jK+SA+p1g8g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4376,9 +4376,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-oxlint": {
|
"node_modules/eslint-plugin-oxlint": {
|
||||||
"version": "1.51.0",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-oxlint/-/eslint-plugin-oxlint-1.53.0.tgz",
|
||||||
"integrity": "sha512-lct8LD1AxfHF1PcsuK6mFYals+zX0mx/WP2G4i16h0iR8jpT3xCfGTmTNwXiImcevzGIiJ/VDBgQ7t0B9z2Jeg==",
|
"integrity": "sha512-rRsxP+11XyYynihsKBosP5/KNgCxNMLxVV4xSypsoCmHXaXnMMe+gscwugFvR4DZI/HCeg7+qR1Ru/aSv1hvSQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5776,9 +5776,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/oxlint": {
|
"node_modules/oxlint": {
|
||||||
"version": "1.51.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.54.0.tgz",
|
||||||
"integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==",
|
"integrity": "sha512-ObSjVwf0ZYA5U5Cmelj0PsCuqCJXsm2TxZ40tgUSAY7Wu0lKAsNjor6cgXHXSys8jOwv1ICjtzouoWHdKGHbZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5791,25 +5791,25 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@oxlint/binding-android-arm-eabi": "1.51.0",
|
"@oxlint/binding-android-arm-eabi": "1.54.0",
|
||||||
"@oxlint/binding-android-arm64": "1.51.0",
|
"@oxlint/binding-android-arm64": "1.54.0",
|
||||||
"@oxlint/binding-darwin-arm64": "1.51.0",
|
"@oxlint/binding-darwin-arm64": "1.54.0",
|
||||||
"@oxlint/binding-darwin-x64": "1.51.0",
|
"@oxlint/binding-darwin-x64": "1.54.0",
|
||||||
"@oxlint/binding-freebsd-x64": "1.51.0",
|
"@oxlint/binding-freebsd-x64": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm-gnueabihf": "1.51.0",
|
"@oxlint/binding-linux-arm-gnueabihf": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm-musleabihf": "1.51.0",
|
"@oxlint/binding-linux-arm-musleabihf": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm64-gnu": "1.51.0",
|
"@oxlint/binding-linux-arm64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-arm64-musl": "1.51.0",
|
"@oxlint/binding-linux-arm64-musl": "1.54.0",
|
||||||
"@oxlint/binding-linux-ppc64-gnu": "1.51.0",
|
"@oxlint/binding-linux-ppc64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-riscv64-gnu": "1.51.0",
|
"@oxlint/binding-linux-riscv64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-riscv64-musl": "1.51.0",
|
"@oxlint/binding-linux-riscv64-musl": "1.54.0",
|
||||||
"@oxlint/binding-linux-s390x-gnu": "1.51.0",
|
"@oxlint/binding-linux-s390x-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-x64-gnu": "1.51.0",
|
"@oxlint/binding-linux-x64-gnu": "1.54.0",
|
||||||
"@oxlint/binding-linux-x64-musl": "1.51.0",
|
"@oxlint/binding-linux-x64-musl": "1.54.0",
|
||||||
"@oxlint/binding-openharmony-arm64": "1.51.0",
|
"@oxlint/binding-openharmony-arm64": "1.54.0",
|
||||||
"@oxlint/binding-win32-arm64-msvc": "1.51.0",
|
"@oxlint/binding-win32-arm64-msvc": "1.54.0",
|
||||||
"@oxlint/binding-win32-ia32-msvc": "1.51.0",
|
"@oxlint/binding-win32-ia32-msvc": "1.54.0",
|
||||||
"@oxlint/binding-win32-x64-msvc": "1.51.0"
|
"@oxlint/binding-win32-x64-msvc": "1.54.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"oxlint-tsgolint": ">=0.15.0"
|
"oxlint-tsgolint": ">=0.15.0"
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
"@vue/tsconfig": "^0.9.0",
|
"@vue/tsconfig": "^0.9.0",
|
||||||
"eslint": "^10.0.2",
|
"eslint": "^10.0.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-oxlint": "~1.51.0",
|
"eslint-plugin-oxlint": "~1.53.0",
|
||||||
"eslint-plugin-vue": "~10.8.0",
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"msw": "^2.12.10",
|
"msw": "^2.12.10",
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"oxlint": "~1.51.0",
|
"oxlint": "~1.54.0",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const router = createRouter({
|
|||||||
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
const NOW = new Date(2026, 2, 11, 12, 0, 0)
|
||||||
|
|
||||||
const mockEvents = [
|
const mockEvents = [
|
||||||
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00', expiryDate: '' },
|
{ eventToken: 'past-1', title: 'Past Event', dateTime: '2026-03-01T10:00:00' },
|
||||||
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00', expiryDate: '' },
|
{ eventToken: 'later-1', title: 'Later Event', dateTime: '2027-06-15T10:00:00' },
|
||||||
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00', expiryDate: '' },
|
{ eventToken: 'today-1', title: 'Today Event', dateTime: '2026-03-11T18:00:00' },
|
||||||
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00', expiryDate: '' },
|
{ eventToken: 'week-1', title: 'This Week Event', dateTime: '2026-03-13T10:00:00' },
|
||||||
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00', expiryDate: '' },
|
{ eventToken: 'nextweek-1', title: 'Next Week Event', dateTime: '2026-03-16T10:00:00' },
|
||||||
]
|
]
|
||||||
|
|
||||||
vi.mock('../../composables/useEventStorage', () => ({
|
vi.mock('../../composables/useEventStorage', () => ({
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ function makeEvent(overrides: Partial<StoredEvent> & { dateTime: string }): Stor
|
|||||||
return {
|
return {
|
||||||
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
eventToken: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
expiryDate: '',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ describe('useEventStorage', () => {
|
|||||||
organizerToken: 'org-456',
|
organizerToken: 'org-456',
|
||||||
title: 'Birthday',
|
title: 'Birthday',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const events = getStoredEvents()
|
const events = getStoredEvents()
|
||||||
@@ -61,7 +60,6 @@ describe('useEventStorage', () => {
|
|||||||
organizerToken: 'org-456',
|
organizerToken: 'org-456',
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
expect(getOrganizerToken('abc-123')).toBe('org-456')
|
||||||
@@ -79,14 +77,12 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'event-1',
|
eventToken: 'event-1',
|
||||||
title: 'First',
|
title: 'First',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
saveCreatedEvent({
|
saveCreatedEvent({
|
||||||
eventToken: 'event-2',
|
eventToken: 'event-2',
|
||||||
title: 'Second',
|
title: 'Second',
|
||||||
dateTime: '2026-07-15T20:00:00+02:00',
|
dateTime: '2026-07-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-08-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const events = getStoredEvents()
|
const events = getStoredEvents()
|
||||||
@@ -102,14 +98,12 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'abc-123',
|
eventToken: 'abc-123',
|
||||||
title: 'Old Title',
|
title: 'Old Title',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
saveCreatedEvent({
|
saveCreatedEvent({
|
||||||
eventToken: 'abc-123',
|
eventToken: 'abc-123',
|
||||||
title: 'New Title',
|
title: 'New Title',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const events = getStoredEvents()
|
const events = getStoredEvents()
|
||||||
@@ -124,7 +118,6 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'abc-123',
|
eventToken: 'abc-123',
|
||||||
title: 'Birthday',
|
title: 'Birthday',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
|
saveRsvp('abc-123', 'rsvp-token-1', 'Max', 'Birthday', '2026-06-15T20:00:00+02:00')
|
||||||
@@ -154,7 +147,6 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'abc-123',
|
eventToken: 'abc-123',
|
||||||
title: 'Test',
|
title: 'Test',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(getRsvp('abc-123')).toBeUndefined()
|
expect(getRsvp('abc-123')).toBeUndefined()
|
||||||
@@ -172,14 +164,12 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'event-1',
|
eventToken: 'event-1',
|
||||||
title: 'First',
|
title: 'First',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
saveCreatedEvent({
|
saveCreatedEvent({
|
||||||
eventToken: 'event-2',
|
eventToken: 'event-2',
|
||||||
title: 'Second',
|
title: 'Second',
|
||||||
dateTime: '2026-07-15T20:00:00+02:00',
|
dateTime: '2026-07-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-08-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
removeEvent('event-1')
|
removeEvent('event-1')
|
||||||
@@ -196,7 +186,6 @@ describe('useEventStorage', () => {
|
|||||||
eventToken: 'event-1',
|
eventToken: 'event-1',
|
||||||
title: 'First',
|
title: 'First',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
removeEvent('nonexistent')
|
removeEvent('nonexistent')
|
||||||
@@ -220,8 +209,7 @@ describe('isValidStoredEvent', () => {
|
|||||||
eventToken: 'abc-123',
|
eventToken: 'abc-123',
|
||||||
title: 'Birthday',
|
title: 'Birthday',
|
||||||
dateTime: '2026-06-15T20:00:00+02:00',
|
dateTime: '2026-06-15T20:00:00+02:00',
|
||||||
expiryDate: '2026-07-15',
|
}),
|
||||||
}),
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export interface StoredEvent {
|
|||||||
organizerToken?: string
|
organizerToken?: string
|
||||||
title: string
|
title: string
|
||||||
dateTime: string
|
dateTime: string
|
||||||
expiryDate: string
|
|
||||||
rsvpToken?: string
|
rsvpToken?: string
|
||||||
rsvpName?: string
|
rsvpName?: string
|
||||||
}
|
}
|
||||||
@@ -66,7 +65,7 @@ export function useEventStorage() {
|
|||||||
existing.rsvpToken = rsvpToken
|
existing.rsvpToken = rsvpToken
|
||||||
existing.rsvpName = rsvpName
|
existing.rsvpName = rsvpName
|
||||||
} else {
|
} else {
|
||||||
events.push({ eventToken, title, dateTime, expiryDate: '', rsvpToken, rsvpName })
|
events.push({ eventToken, title, dateTime, rsvpToken, rsvpName })
|
||||||
}
|
}
|
||||||
writeEvents(events)
|
writeEvents(events)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,21 +65,6 @@
|
|||||||
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
<span v-if="errors.location" id="location-error" class="field-error" role="alert">{{ errors.location }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="expiryDate" class="form-label">Expiry Date *</label>
|
|
||||||
<input
|
|
||||||
id="expiryDate"
|
|
||||||
v-model="form.expiryDate"
|
|
||||||
type="date"
|
|
||||||
class="form-field glass"
|
|
||||||
required
|
|
||||||
:min="tomorrow"
|
|
||||||
:aria-invalid="!!errors.expiryDate"
|
|
||||||
:aria-describedby="errors.expiryDate ? 'expiryDate-error' : undefined"
|
|
||||||
/>
|
|
||||||
<span v-if="errors.expiryDate" id="expiryDate-error" class="field-error" role="alert">{{ errors.expiryDate }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
<button type="submit" class="btn-primary glass" :disabled="submitting">
|
||||||
{{ submitting ? 'Creating…' : 'Create Event' }}
|
{{ submitting ? 'Creating…' : 'Create Event' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -90,7 +75,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, watch } from 'vue'
|
import { reactive, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import { useEventStorage } from '@/composables/useEventStorage'
|
import { useEventStorage } from '@/composables/useEventStorage'
|
||||||
@@ -103,7 +88,6 @@ const form = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
dateTime: '',
|
dateTime: '',
|
||||||
location: '',
|
location: '',
|
||||||
expiryDate: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = reactive({
|
const errors = reactive({
|
||||||
@@ -111,31 +95,22 @@ const errors = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
dateTime: '',
|
dateTime: '',
|
||||||
location: '',
|
location: '',
|
||||||
expiryDate: '',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const serverError = ref('')
|
const serverError = ref('')
|
||||||
|
|
||||||
const tomorrow = computed(() => {
|
|
||||||
const d = new Date()
|
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
return d.toISOString().split('T')[0]
|
|
||||||
})
|
|
||||||
|
|
||||||
function clearErrors() {
|
function clearErrors() {
|
||||||
errors.title = ''
|
errors.title = ''
|
||||||
errors.description = ''
|
errors.description = ''
|
||||||
errors.dateTime = ''
|
errors.dateTime = ''
|
||||||
errors.location = ''
|
errors.location = ''
|
||||||
errors.expiryDate = ''
|
|
||||||
serverError.value = ''
|
serverError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear individual field errors when the user types
|
// Clear individual field errors when the user types
|
||||||
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
watch(() => form.title, () => { errors.title = ''; serverError.value = '' })
|
||||||
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
watch(() => form.dateTime, () => { errors.dateTime = ''; serverError.value = '' })
|
||||||
watch(() => form.expiryDate, () => { errors.expiryDate = ''; serverError.value = '' })
|
|
||||||
watch(() => form.description, () => { serverError.value = '' })
|
watch(() => form.description, () => { serverError.value = '' })
|
||||||
watch(() => form.location, () => { serverError.value = '' })
|
watch(() => form.location, () => { serverError.value = '' })
|
||||||
|
|
||||||
@@ -153,14 +128,6 @@ function validate(): boolean {
|
|||||||
valid = false
|
valid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!form.expiryDate) {
|
|
||||||
errors.expiryDate = 'Expiry date is required.'
|
|
||||||
valid = false
|
|
||||||
} else if (form.expiryDate <= (new Date().toISOString().split('T')[0] ?? '')) {
|
|
||||||
errors.expiryDate = 'Expiry date must be in the future.'
|
|
||||||
valid = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid
|
return valid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +153,6 @@ async function handleSubmit() {
|
|||||||
dateTime: dateTimeWithOffset,
|
dateTime: dateTimeWithOffset,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
location: form.location.trim() || undefined,
|
location: form.location.trim() || undefined,
|
||||||
expiryDate: form.expiryDate,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +178,6 @@ async function handleSubmit() {
|
|||||||
organizerToken: data.organizerToken,
|
organizerToken: data.organizerToken,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
dateTime: data.dateTime,
|
dateTime: data.dateTime,
|
||||||
expiryDate: data.expiryDate,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
router.push({ name: 'event', params: { eventToken: data.eventToken } })
|
||||||
|
|||||||
@@ -25,10 +25,6 @@
|
|||||||
|
|
||||||
<!-- Loaded state -->
|
<!-- Loaded state -->
|
||||||
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
<div v-else-if="state === 'loaded' && event" class="detail__content">
|
||||||
<div v-if="event.expired" class="detail__banner detail__banner--expired" role="status">
|
|
||||||
This event has ended.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="detail__title">{{ event.title }}</h1>
|
<h1 class="detail__title">{{ event.title }}</h1>
|
||||||
|
|
||||||
<dl class="detail__meta">
|
<dl class="detail__meta">
|
||||||
@@ -74,9 +70,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RSVP bar (only for loaded, non-expired events) -->
|
<!-- RSVP bar -->
|
||||||
<RsvpBar
|
<RsvpBar
|
||||||
v-if="state === 'loaded' && event && !event.expired && !isOrganizer"
|
v-if="state === 'loaded' && event && !isOrganizer"
|
||||||
:has-rsvp="!!rsvpName"
|
:has-rsvp="!!rsvpName"
|
||||||
@open="sheetOpen = true"
|
@open="sheetOpen = true"
|
||||||
/>
|
/>
|
||||||
@@ -412,12 +408,6 @@ onMounted(fetchEvent)
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail__banner--expired {
|
|
||||||
background: var(--color-glass);
|
|
||||||
color: var(--color-text-soft);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Error / not-found message */
|
/* Error / not-found message */
|
||||||
.detail__message {
|
.detail__message {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ describe('EventCreateView', () => {
|
|||||||
expect(wrapper.find('#description').exists()).toBe(true)
|
expect(wrapper.find('#description').exists()).toBe(true)
|
||||||
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
expect(wrapper.find('#dateTime').exists()).toBe(true)
|
||||||
expect(wrapper.find('#location').exists()).toBe(true)
|
expect(wrapper.find('#location').exists()).toBe(true)
|
||||||
expect(wrapper.find('#expiryDate').exists()).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has required attribute on required fields', async () => {
|
it('has required attribute on required fields', async () => {
|
||||||
@@ -58,7 +57,6 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
expect(wrapper.find('#title').attributes('required')).toBeDefined()
|
||||||
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
expect(wrapper.find('#dateTime').attributes('required')).toBeDefined()
|
||||||
expect(wrapper.find('#expiryDate').attributes('required')).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not have required attribute on optional fields', async () => {
|
it('does not have required attribute on optional fields', async () => {
|
||||||
@@ -102,7 +100,6 @@ describe('EventCreateView', () => {
|
|||||||
// Fill required fields
|
// Fill required fields
|
||||||
await wrapper.find('#title').setValue('My Event')
|
await wrapper.find('#title').setValue('My Event')
|
||||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -127,7 +124,7 @@ describe('EventCreateView', () => {
|
|||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
const errorsBefore = wrapper.findAll('[role="alert"]').map((el) => el.text()).filter((t) => t.length > 0)
|
||||||
expect(errorsBefore.length).toBeGreaterThanOrEqual(3)
|
expect(errorsBefore.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
// Type into title field
|
// Type into title field
|
||||||
await wrapper.find('#title').setValue('My Event')
|
await wrapper.find('#title').setValue('My Event')
|
||||||
@@ -138,9 +135,6 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
const dateTimeError = wrapper.find('#dateTime').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
||||||
expect(dateTimeError.textContent).not.toBe('')
|
expect(dateTimeError.textContent).not.toBe('')
|
||||||
|
|
||||||
const expiryError = wrapper.find('#expiryDate').element.closest('.form-group')!.querySelector('[role="alert"]')!
|
|
||||||
expect(expiryError.textContent).not.toBe('')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows validation errors when submitting empty form', async () => {
|
it('shows validation errors when submitting empty form', async () => {
|
||||||
@@ -156,7 +150,7 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
const errorElements = wrapper.findAll('[role="alert"]')
|
const errorElements = wrapper.findAll('[role="alert"]')
|
||||||
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
const errorTexts = errorElements.map((el) => el.text()).filter((t) => t.length > 0)
|
||||||
expect(errorTexts.length).toBeGreaterThanOrEqual(3)
|
expect(errorTexts.length).toBeGreaterThanOrEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
it('submits successfully, saves to storage, and navigates to event page', async () => {
|
||||||
@@ -179,7 +173,6 @@ describe('EventCreateView', () => {
|
|||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
dateTime: '2026-12-25T18:00:00+01:00',
|
dateTime: '2026-12-25T18:00:00+01:00',
|
||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
expiryDate: '2026-12-24',
|
|
||||||
},
|
},
|
||||||
error: undefined,
|
error: undefined,
|
||||||
response: new Response(),
|
response: new Response(),
|
||||||
@@ -198,7 +191,6 @@ describe('EventCreateView', () => {
|
|||||||
await wrapper.find('#description').setValue('Come celebrate!')
|
await wrapper.find('#description').setValue('Come celebrate!')
|
||||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
await wrapper.find('#location').setValue('Berlin')
|
await wrapper.find('#location').setValue('Berlin')
|
||||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -208,7 +200,6 @@ describe('EventCreateView', () => {
|
|||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
description: 'Come celebrate!',
|
description: 'Come celebrate!',
|
||||||
location: 'Berlin',
|
location: 'Berlin',
|
||||||
expiryDate: '2026-12-24',
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -217,7 +208,6 @@ describe('EventCreateView', () => {
|
|||||||
organizerToken: 'org-456',
|
organizerToken: 'org-456',
|
||||||
title: 'Birthday Party',
|
title: 'Birthday Party',
|
||||||
dateTime: '2026-12-25T18:00:00+01:00',
|
dateTime: '2026-12-25T18:00:00+01:00',
|
||||||
expiryDate: '2026-12-24',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(pushSpy).toHaveBeenCalledWith({
|
expect(pushSpy).toHaveBeenCalledWith({
|
||||||
@@ -245,7 +235,6 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
await wrapper.find('#title').setValue('Duplicate Event')
|
await wrapper.find('#title').setValue('Duplicate Event')
|
||||||
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
await wrapper.find('#dateTime').setValue('2026-12-25T18:00')
|
||||||
await wrapper.find('#expiryDate').setValue('2026-12-24')
|
|
||||||
|
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -256,6 +245,5 @@ describe('EventCreateView', () => {
|
|||||||
|
|
||||||
// Other field errors should not be present
|
// Other field errors should not be present
|
||||||
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
expect(wrapper.find('#dateTime-error').exists()).toBe(false)
|
||||||
expect(wrapper.find('#expiryDate-error').exists()).toBe(false)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const fullEvent = {
|
|||||||
timezone: 'Europe/Berlin',
|
timezone: 'Europe/Berlin',
|
||||||
location: 'Central Park, NYC',
|
location: 'Central Park, NYC',
|
||||||
attendeeCount: 12,
|
attendeeCount: 12,
|
||||||
expired: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockLoadedEvent(eventOverrides = {}) {
|
function mockLoadedEvent(eventOverrides = {}) {
|
||||||
@@ -124,29 +123,6 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expired state
|
|
||||||
it('renders "event has ended" banner when expired', async () => {
|
|
||||||
mockLoadedEvent({ expired: true })
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('This event has ended.')
|
|
||||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(true)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
// No expired banner when not expired
|
|
||||||
it('does not render expired banner when event is active', async () => {
|
|
||||||
mockLoadedEvent()
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('.detail__banner--expired').exists()).toBe(false)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Not found state
|
// Not found state
|
||||||
it('renders "event not found" when API returns 404', async () => {
|
it('renders "event not found" when API returns 404', async () => {
|
||||||
vi.mocked(api.GET).mockResolvedValue({
|
vi.mocked(api.GET).mockResolvedValue({
|
||||||
@@ -229,17 +205,6 @@ describe('EventDetailView', () => {
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not show RSVP bar on expired event', async () => {
|
|
||||||
mockLoadedEvent({ expired: true })
|
|
||||||
|
|
||||||
const wrapper = await mountWithToken()
|
|
||||||
await flushPromises()
|
|
||||||
|
|
||||||
expect(wrapper.find('.rsvp-bar__cta').exists()).toBe(false)
|
|
||||||
expect(wrapper.find('.rsvp-bar').exists()).toBe(false)
|
|
||||||
wrapper.unmount()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
it('shows RSVP status bar when localStorage has RSVP', async () => {
|
||||||
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
mockGetRsvp.mockReturnValue({ rsvpToken: 'rsvp-1', rsvpName: 'Max' })
|
||||||
mockLoadedEvent()
|
mockLoadedEvent()
|
||||||
|
|||||||
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
34
specs/013-auto-delete-expired/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
38
specs/013-auto-delete-expired/data-model.md
Normal file
38
specs/013-auto-delete-expired/data-model.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Data Model: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Existing Entities (no changes)
|
||||||
|
|
||||||
|
### Event
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| event_token | UUID | Public identifier |
|
||||||
|
| organizer_token | UUID | Organizer access |
|
||||||
|
| title | VARCHAR(200) | Required |
|
||||||
|
| description | VARCHAR(2000) | Optional |
|
||||||
|
| date_time | TIMESTAMPTZ | Event date/time |
|
||||||
|
| location | VARCHAR(500) | Optional |
|
||||||
|
| expiry_date | DATE | **Deletion trigger** — indexed (`idx_events_expiry_date`) |
|
||||||
|
| created_at | TIMESTAMPTZ | Auto-set |
|
||||||
|
|
||||||
|
### RSVP
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | BIGSERIAL | PK, internal |
|
||||||
|
| rsvp_token | UUID | Public identifier |
|
||||||
|
| event_id | BIGINT | FK → events(id), **ON DELETE CASCADE** |
|
||||||
|
| name | VARCHAR(100) | Guest name |
|
||||||
|
|
||||||
|
## Deletion Behavior
|
||||||
|
|
||||||
|
- `DELETE FROM events WHERE expiry_date < CURRENT_DATE` removes expired events.
|
||||||
|
- RSVPs are automatically cascade-deleted by the FK constraint `fk_rsvps_event_id` with `ON DELETE CASCADE`.
|
||||||
|
- No new tables, columns, or migrations required.
|
||||||
|
|
||||||
|
## Indexes Used
|
||||||
|
|
||||||
|
- `idx_events_expiry_date` on `events(expiry_date)` — ensures the cleanup query is efficient.
|
||||||
90
specs/013-auto-delete-expired/plan.md
Normal file
90
specs/013-auto-delete-expired/plan.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Implementation Plan: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Branch**: `013-auto-delete-expired` | **Date**: 2026-03-09 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/013-auto-delete-expired/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a scheduled background job that runs daily and deletes all events whose `expiryDate` has passed. Deletion is performed via a native SQL query (`DELETE FROM events WHERE expiry_date < CURRENT_DATE`). Cascade deletion of RSVPs is handled by the existing `ON DELETE CASCADE` FK constraint. No API or frontend changes required.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Java 25, Spring Boot 3.5.x
|
||||||
|
**Primary Dependencies**: Spring Scheduling (`@Scheduled`), Spring Data JPA (for native query)
|
||||||
|
**Storage**: PostgreSQL (existing, Liquibase migrations)
|
||||||
|
**Testing**: JUnit 5, Spring Boot Test, Testcontainers (existing)
|
||||||
|
**Target Platform**: Linux server (Docker)
|
||||||
|
**Project Type**: Web service (backend only for this feature)
|
||||||
|
**Performance Goals**: N/A — daily batch job on small dataset
|
||||||
|
**Constraints**: Single native DELETE query, no entity loading
|
||||||
|
**Scale/Scope**: Self-hosted, small-scale — typically < 100 events total
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Privacy by Design | PASS | Deleting expired data supports privacy goals. No PII logged. |
|
||||||
|
| II. Test-Driven Methodology | PASS | Tests written before implementation (TDD). |
|
||||||
|
| III. API-First Development | N/A | No API changes — this is a backend-internal job. |
|
||||||
|
| IV. Simplicity & Quality | PASS | Single query, no over-engineering. |
|
||||||
|
| V. Dependency Discipline | PASS | Uses only existing Spring dependencies (`@Scheduled`). |
|
||||||
|
| VI. Accessibility | N/A | No frontend changes. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/013-auto-delete-expired/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
├── research.md # Phase 0 output (minimal — no unknowns)
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md # Spec quality checklist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/src/main/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJob.java # NEW: Scheduled job
|
||||||
|
├── domain/port/out/
|
||||||
|
│ └── EventRepository.java # MODIFIED: Add deleteExpired method
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
├── EventJpaRepository.java # MODIFIED: Add native DELETE query
|
||||||
|
└── EventPersistenceAdapter.java # MODIFIED: Implement deleteExpired
|
||||||
|
|
||||||
|
backend/src/test/java/de/fete/
|
||||||
|
├── application/service/
|
||||||
|
│ └── ExpiredEventCleanupJobTest.java # NEW: Unit test for job
|
||||||
|
└── adapter/out/persistence/
|
||||||
|
└── EventPersistenceAdapterIntegrationTest.java # NEW or MODIFIED: Integration test for native query
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Backend-only change. Follows existing hexagonal architecture: port defines the contract, adapter implements with native query, service layer schedules the job.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Hexagonal Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
@Scheduled trigger
|
||||||
|
→ ExpiredEventCleanupJob (application/service)
|
||||||
|
→ EventRepository.deleteExpired() (domain/port/out)
|
||||||
|
→ EventPersistenceAdapter.deleteExpired() (adapter/out/persistence)
|
||||||
|
→ EventJpaRepository native query (adapter/out/persistence)
|
||||||
|
→ DELETE FROM events WHERE expiry_date < CURRENT_DATE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Decisions
|
||||||
|
|
||||||
|
1. **Port method**: `int deleteExpired()` on `EventRepository` — returns count of deleted events for logging.
|
||||||
|
2. **Native query**: `@Modifying @Query(value = "DELETE FROM events WHERE expiry_date < CURRENT_DATE", nativeQuery = true)` on `EventJpaRepository`.
|
||||||
|
3. **Schedule**: `@Scheduled(cron = "0 0 3 * * *")` — runs daily at 03:00 server time. Low-traffic window.
|
||||||
|
4. **Logging**: INFO-level log after each run: `"Deleted {count} expired event(s)"`. No log if count is 0 (or DEBUG-level).
|
||||||
|
5. **Transaction**: The native DELETE runs in a single transaction — atomic, no partial state.
|
||||||
|
6. **Enable scheduling**: Add `@EnableScheduling` to `FeteApplication` (or a config class).
|
||||||
31
specs/013-auto-delete-expired/research.md
Normal file
31
specs/013-auto-delete-expired/research.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Research: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature**: 013-auto-delete-expired | **Date**: 2026-03-09
|
||||||
|
|
||||||
|
## Deletion Strategy
|
||||||
|
|
||||||
|
- **Decision**: Direct native SQL DELETE query via Spring Data JPA `@Query`.
|
||||||
|
- **Rationale**: Simplest approach. No entity loading overhead. The existing `ON DELETE CASCADE` FK constraint on `fk_rsvps_event_id` (migration `003-create-rsvps-table.xml`) handles cascading deletion of RSVPs automatically. The existing `idx_events_expiry_date` index ensures the WHERE clause is efficient.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- JPA repository `deleteAll(findExpired())`: Loads entities into memory first, unnecessary overhead.
|
||||||
|
- Database-level cron (`pg_cron`): Less portable, adds external dependency.
|
||||||
|
- Soft delete with lazy cleanup: Over-engineered for fete's scale and privacy goals.
|
||||||
|
- No deletion (filter only): Contradicts privacy-by-design principle.
|
||||||
|
|
||||||
|
## Scheduling Mechanism
|
||||||
|
|
||||||
|
- **Decision**: Spring `@Scheduled(cron = ...)` annotation.
|
||||||
|
- **Rationale**: Already available in Spring Boot, no additional dependencies. Simple, declarative, well-tested.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Quartz Scheduler: Too heavy for a single daily job.
|
||||||
|
- External cron (OS-level): Requires separate process management, harder to test.
|
||||||
|
|
||||||
|
## Transaction Behavior
|
||||||
|
|
||||||
|
- **Decision**: Single transaction for the DELETE statement.
|
||||||
|
- **Rationale**: A single `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is atomic. If the DB connection drops mid-execution, the transaction rolls back and no events are partially deleted. The next run picks up all expired events.
|
||||||
|
|
||||||
|
## Enabling @Scheduled
|
||||||
|
|
||||||
|
- **Decision**: Add `@EnableScheduling` to `FeteApplication.java`.
|
||||||
|
- **Rationale**: Simplest approach. Only one scheduled job exists, no need for a separate config class.
|
||||||
71
specs/013-auto-delete-expired/spec.md
Normal file
71
specs/013-auto-delete-expired/spec.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Feature Specification: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Feature Branch**: `013-auto-delete-expired`
|
||||||
|
**Created**: 2026-03-09
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Delete events automatically after they expired"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
As a self-hoster, I want expired events to be automatically deleted from the database so that personal data is removed without manual intervention and storage stays clean.
|
||||||
|
|
||||||
|
A scheduled background job periodically checks all events. Any event whose `expiryDate` is in the past gets permanently deleted — including all associated data (RSVPs, tokens). No user action is required; the system handles this autonomously.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core and only feature — automated, hands-off cleanup of expired events. It directly supports the privacy promise of fete.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating events with past expiry dates, triggering the cleanup job, and verifying the events are gone from the database.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an event with an `expiryDate` in the past, **When** the scheduled cleanup job runs, **Then** the event and all its associated data (RSVPs, tokens) are permanently deleted.
|
||||||
|
2. **Given** an event with an `expiryDate` in the future, **When** the scheduled cleanup job runs, **Then** the event remains untouched.
|
||||||
|
3. **Given** multiple expired events exist, **When** the cleanup job runs, **Then** all expired events are deleted in a single run.
|
||||||
|
4. **Given** no expired events exist, **When** the cleanup job runs, **Then** nothing is deleted and the job completes without error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens if the cleanup job fails mid-deletion (e.g., database connection lost)? Events that were not yet deleted remain and will be picked up in the next run. No partial state.
|
||||||
|
- What happens if the server was offline for an extended period? On the next run, all accumulated expired events are deleted — no special catch-up logic needed.
|
||||||
|
- What happens if an organizer is viewing their event page while it gets deleted? The page shows a "not found" state on next interaction. This is acceptable because the event has expired.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST automatically delete events whose `expiryDate` is before the current date/time.
|
||||||
|
- **FR-002**: When an event is deleted, all associated data (RSVPs, organizer tokens, event tokens) MUST be deleted as well (cascade delete).
|
||||||
|
- **FR-003**: The cleanup job MUST run on a recurring schedule (default: once daily).
|
||||||
|
- **FR-004**: The cleanup job MUST be idempotent — running it multiple times produces the same result.
|
||||||
|
- **FR-005**: The cleanup job MUST log how many events were deleted per run.
|
||||||
|
- **FR-006**: Events that have not yet expired MUST NOT be affected by the cleanup job.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Event**: The primary entity being cleaned up. Has an `expiryDate` field that determines when it becomes eligible for deletion.
|
||||||
|
- **RSVP**: Associated guest responses linked to an event. Deleted when the parent event is deleted.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Expired events are deleted within 24 hours of their expiry date without manual intervention.
|
||||||
|
- **SC-002**: Zero data residue — when an event is deleted, no orphaned RSVPs or tokens remain in the system.
|
||||||
|
- **SC-003**: The cleanup process completes without errors under normal operating conditions.
|
||||||
|
- **SC-004**: Non-expired events are never affected by the cleanup process.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-09
|
||||||
|
|
||||||
|
- Q: How should deletion be implemented — application code (JPA) or direct database query? → A: Direct database query. A single native `DELETE FROM events WHERE expiry_date < CURRENT_DATE` is sufficient. The existing `ON DELETE CASCADE` on the RSVPs foreign key ensures associated data is removed automatically. The existing `idx_events_expiry_date` index ensures the query is performant.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The `expiryDate` field already exists on events and is auto-set to `eventDate + 7 days` (implemented in the previous feature).
|
||||||
|
- Cascade deletion of associated data (RSVPs, tokens) is handled at the database level via foreign key constraints (`ON DELETE CASCADE` on `fk_rsvps_event_id`, verified in migration `003-create-rsvps-table.xml`).
|
||||||
|
- The daily schedule is sufficient — there is no requirement for near-real-time deletion.
|
||||||
|
- No "grace period" or "soft delete" is needed — events are permanently removed once expired.
|
||||||
101
specs/013-auto-delete-expired/tasks.md
Normal file
101
specs/013-auto-delete-expired/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Tasks: Auto-Delete Expired Events
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/013-auto-delete-expired/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Included — constitution mandates TDD (Principle II).
|
||||||
|
|
||||||
|
**Organization**: Single user story (US1), so phases are compact.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Enable Spring scheduling in the application.
|
||||||
|
|
||||||
|
- [x] T001 Add `@EnableScheduling` annotation to `backend/src/main/java/de/fete/FeteApplication.java`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Automatic Cleanup of Expired Events (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: A daily scheduled job deletes all events whose `expiryDate` is in the past, including cascaded RSVPs.
|
||||||
|
|
||||||
|
**Independent Test**: Create events with past expiry dates, trigger the cleanup job, verify events and RSVPs are gone.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T002 [P] [US1] Unit test for `ExpiredEventCleanupJob` in `backend/src/test/java/de/fete/application/service/ExpiredEventCleanupJobTest.java` — verify job calls `deleteExpired()` on repository and logs the count
|
||||||
|
- [x] T003 [P] [US1] Integration test for native DELETE query in `backend/src/test/java/de/fete/adapter/out/persistence/EventPersistenceAdapterIntegrationTest.java` — verify expired events + RSVPs are deleted, non-expired events survive
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [P] [US1] Add `int deleteExpired()` method to port interface `backend/src/main/java/de/fete/domain/port/out/EventRepository.java`
|
||||||
|
- [x] T005 [P] [US1] Add native `@Modifying @Query("DELETE FROM events WHERE expiry_date < CURRENT_DATE")` method to `backend/src/main/java/de/fete/adapter/out/persistence/EventJpaRepository.java`
|
||||||
|
- [x] T006 [US1] Implement `deleteExpired()` in `backend/src/main/java/de/fete/adapter/out/persistence/EventPersistenceAdapter.java` — delegates to JPA repository native query
|
||||||
|
- [x] T007 [US1] Create `ExpiredEventCleanupJob` with `@Scheduled(cron = "0 0 3 * * *")` in `backend/src/main/java/de/fete/application/service/ExpiredEventCleanupJob.java` — calls `deleteExpired()`, logs count at INFO level
|
||||||
|
|
||||||
|
**Checkpoint**: All tests pass. Expired events are automatically deleted daily at 03:00.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on Setup completion
|
||||||
|
|
||||||
|
### Within User Story 1
|
||||||
|
|
||||||
|
- T002, T003 (tests) can run in parallel — write first, must fail (RED)
|
||||||
|
- T004, T005 (port + JPA query) can run in parallel — different files
|
||||||
|
- T006 depends on T004 + T005 (adapter implements port, delegates to JPA)
|
||||||
|
- T007 depends on T006 (job calls adapter via port)
|
||||||
|
- After T007: all tests should pass (GREEN)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
```
|
||||||
|
T002 ──┐
|
||||||
|
├── (tests written, failing)
|
||||||
|
T003 ──┘
|
||||||
|
|
||||||
|
T004 ──┐
|
||||||
|
├── T006 ── T007 ── (all tests green)
|
||||||
|
T005 ──┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (this is the MVP — single story)
|
||||||
|
|
||||||
|
1. T001: Enable scheduling
|
||||||
|
2. T002 + T003: Write failing tests (RED)
|
||||||
|
3. T004 + T005: Port interface + native query (parallel)
|
||||||
|
4. T006: Adapter implementation
|
||||||
|
5. T007: Scheduled job
|
||||||
|
6. Verify all tests pass (GREEN)
|
||||||
|
7. Done — commit and ship
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Total tasks: 7
|
||||||
|
- User Story 1 tasks: 6 (T002–T007)
|
||||||
|
- Setup tasks: 1 (T001)
|
||||||
|
- Parallel opportunities: T002||T003, T004||T005
|
||||||
|
- No frontend changes needed
|
||||||
|
- No API/OpenAPI changes needed
|
||||||
|
- No new migrations needed (existing schema + FK constraints sufficient)
|
||||||
Reference in New Issue
Block a user