From 2131c9c67cb11db5fe8400bd91e8f708baa18edf Mon Sep 17 00:00:00 2001 From: f-s-h Date: Fri, 26 Jun 2026 10:37:47 +0200 Subject: [PATCH 1/3] Implemented EventService --- .../eventservice/config/SecurityConfig.java | 2 +- .../controller/EventController.java | 82 +++++ .../{ => controller}/HelloController.java | 2 +- .../converter/EventConverter.java | 57 +++ .../exception/BadRequestException.java | 7 + .../exception/ForbiddenException.java | 7 + .../exception/GlobalExceptionHandler.java | 30 ++ .../exception/NotFoundException.java | 7 + .../eventservice/service/EventService.java | 176 +++++++++ .../EventServiceApplicationTests.java | 10 + .../controller/EventControllerTest.java | 336 ++++++++++++++++++ .../{ => controller}/HelloControllerTest.java | 2 +- 12 files changed, 715 insertions(+), 3 deletions(-) create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/controller/EventController.java rename services/spring-event/src/main/java/tum/devoops/eventservice/{ => controller}/HelloController.java (86%) create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/exception/BadRequestException.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/exception/ForbiddenException.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/exception/GlobalExceptionHandler.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/exception/NotFoundException.java create mode 100644 services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java create mode 100644 services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java rename services/spring-event/src/test/java/tum/devoops/eventservice/{ => controller}/HelloControllerTest.java (97%) diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/config/SecurityConfig.java b/services/spring-event/src/main/java/tum/devoops/eventservice/config/SecurityConfig.java index 19c6f26..f13dabb 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/config/SecurityConfig.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/config/SecurityConfig.java @@ -17,7 +17,7 @@ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@EnableMethodSecurity(proxyTargetClass = true) public class SecurityConfig { @Bean diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/controller/EventController.java b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/EventController.java new file mode 100644 index 0000000..3a656e3 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/EventController.java @@ -0,0 +1,82 @@ +package tum.devoops.eventservice.controller; + +import java.util.List; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.RestController; + +import tum.devoops.eventservice.api.EventsApi; +import tum.devoops.eventservice.model.Event; +import tum.devoops.eventservice.model.EventCreate; +import tum.devoops.eventservice.model.EventPartialUpdate; +import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.service.EventService; + +@RestController +@PreAuthorize("hasAnyRole('admin', 'member')") +public class EventController implements EventsApi { + + private final EventService eventService; + + public EventController(EventService eventService) { + this.eventService = eventService; + } + + @Override + public ResponseEntity> getAllEvents() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(eventService.getAllEvents(requesterId, isAdmin)); + } + + @Override + public ResponseEntity createEvent(EventCreate eventCreate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + Event created = eventService.createEvent(eventCreate, requesterId, isAdmin); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @Override + public ResponseEntity getEventDetails(UUID eventId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(eventService.getEventDetails(eventId, requesterId, isAdmin)); + } + + @Override + public ResponseEntity updateEventDetails(UUID eventId, EventPartialUpdate eventPartialUpdate) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + return ResponseEntity.ok(eventService.updateEventDetails(eventId, eventPartialUpdate, requesterId, isAdmin)); + } + + @Override + public ResponseEntity deleteEvent(UUID eventId) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + UUID requesterId = extractRequesterId(auth); + boolean isAdmin = extractIsAdmin(auth); + eventService.deleteEvent(eventId, requesterId, isAdmin); + return ResponseEntity.noContent().build(); + } + + private UUID extractRequesterId(Authentication auth) { + Jwt jwt = (Jwt) auth.getPrincipal(); + return UUID.fromString(jwt.getSubject()); + } + + private boolean extractIsAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> "ROLE_admin".equals(a.getAuthority())); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/HelloController.java b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java similarity index 86% rename from services/spring-event/src/main/java/tum/devoops/eventservice/HelloController.java rename to services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java index 213edd2..73e460f 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/HelloController.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/controller/HelloController.java @@ -1,4 +1,4 @@ -package tum.devoops.eventservice; +package tum.devoops.eventservice.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java b/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java new file mode 100644 index 0000000..2f85925 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/converter/EventConverter.java @@ -0,0 +1,57 @@ +package tum.devoops.eventservice.converter; + +import java.time.ZoneOffset; +import java.util.List; +import java.util.stream.Collectors; + +import tum.devoops.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.EventEntity; +import tum.devoops.eventservice.entity.SportEventEntity; +import tum.devoops.eventservice.entity.TeamEventEntity; +import tum.devoops.eventservice.model.Event; +import tum.devoops.eventservice.model.EventSummary; + +/** + * Maps {@link EventEntity} (and its link entities) to the API models. + * + *

Stateless and dependency-free: callers supply the already-fetched link entities so this + * converter performs no data access. + */ +public final class EventConverter { + + private EventConverter() { + } + + public static Event toEvent(EventEntity entity, + List attendances, + List sports, + List teams) { + Event event = new Event( + entity.getId(), + entity.getName(), + entity.getDescription(), + entity.getStartTime().atOffset(ZoneOffset.UTC), + entity.getEndTime().atOffset(ZoneOffset.UTC), + entity.getCreatorId().toString() + ); + event.setAttendees(attendances.stream() + .map(a -> a.getId().getMemberId().toString()) + .collect(Collectors.toList())); + event.setSportsLinked(sports.stream() + .map(s -> s.getId().getSportName()) + .collect(Collectors.toList())); + event.setTeamsLinked(teams.stream() + .map(t -> t.getId().getTeamId().toString()) + .collect(Collectors.toList())); + return event; + } + + public static EventSummary toSummary(EventEntity entity) { + return new EventSummary( + entity.getId(), + entity.getName(), + entity.getStartTime().atOffset(ZoneOffset.UTC), + entity.getEndTime().atOffset(ZoneOffset.UTC) + ); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/exception/BadRequestException.java b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/BadRequestException.java new file mode 100644 index 0000000..8a24ff0 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/BadRequestException.java @@ -0,0 +1,7 @@ +package tum.devoops.eventservice.exception; + +public class BadRequestException extends RuntimeException { + public BadRequestException(String message) { + super(message); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/exception/ForbiddenException.java b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/ForbiddenException.java new file mode 100644 index 0000000..4460dfa --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package tum.devoops.eventservice.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/exception/GlobalExceptionHandler.java b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..80c837f --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/GlobalExceptionHandler.java @@ -0,0 +1,30 @@ +package tum.devoops.eventservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import tum.devoops.eventservice.model.BadRequestResponse; +import tum.devoops.eventservice.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(new ErrorResponse().message(ex.getMessage())); + } + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequest(BadRequestException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new BadRequestResponse().message(ex.getMessage())); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/exception/NotFoundException.java b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/NotFoundException.java new file mode 100644 index 0000000..76a3ea5 --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/exception/NotFoundException.java @@ -0,0 +1,7 @@ +package tum.devoops.eventservice.exception; + +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java new file mode 100644 index 0000000..b9356cb --- /dev/null +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java @@ -0,0 +1,176 @@ +package tum.devoops.eventservice.service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import tum.devoops.eventservice.converter.EventConverter; +import tum.devoops.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.EventEntity; +import tum.devoops.eventservice.entity.SportEventEntity; +import tum.devoops.eventservice.entity.TeamEventEntity; +import tum.devoops.eventservice.exception.BadRequestException; +import tum.devoops.eventservice.exception.ForbiddenException; +import tum.devoops.eventservice.exception.NotFoundException; +import tum.devoops.eventservice.model.Event; +import tum.devoops.eventservice.model.EventCreate; +import tum.devoops.eventservice.model.EventPartialUpdate; +import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.repository.AttendanceRepository; +import tum.devoops.eventservice.repository.EventRepository; +import tum.devoops.eventservice.repository.SportEventRepository; +import tum.devoops.eventservice.repository.TeamEventRepository; + +@Service +public class EventService { + + @Autowired + private EventRepository eventRepository; + @Autowired + private AttendanceRepository attendanceRepository; + @Autowired + private SportEventRepository sportEventRepository; + @Autowired + private TeamEventRepository teamEventRepository; + + @Transactional(readOnly = true) + public List getAllEvents(UUID requesterId, boolean isAdmin) { + List entities; + if (isAdmin) { + entities = eventRepository.findAll(); + } else { + Map byId = new LinkedHashMap<>(); + for (EventEntity entity : eventRepository.findAllByCreatorId(requesterId)) { + byId.putIfAbsent(entity.getId(), entity); + } + for (AttendanceEntity attendance : attendanceRepository.findAllById_MemberId(requesterId)) { + UUID eventId = attendance.getId().getEventId(); + if (!byId.containsKey(eventId)) { + eventRepository.findById(eventId).ifPresent(e -> byId.put(eventId, e)); + } + } + entities = new ArrayList<>(byId.values()); + } + return entities.stream().map(EventConverter::toSummary).collect(Collectors.toList()); + } + + @Transactional + public Event createEvent(EventCreate body, UUID requesterId, boolean isAdmin) { + if (body.getStartTime() == null || body.getEndTime() == null + || !body.getEndTime().isAfter(body.getStartTime())) { + throw new BadRequestException("Event end time must be after start time"); + } + + EventEntity entity = new EventEntity(); + entity.setName(body.getName()); + entity.setDescription(body.getDescription()); + entity.setStartTime(body.getStartTime().toInstant()); + entity.setEndTime(body.getEndTime().toInstant()); + entity.setCreatorId(requesterId); + + EventEntity saved = eventRepository.save(entity); + persistLinks(saved.getId(), body.getAttendees(), body.getSportsLinked(), body.getTeamsLinked()); + + return toEvent(saved); + } + + @Transactional(readOnly = true) + public Event getEventDetails(UUID eventId, UUID requesterId, boolean isAdmin) { + EventEntity entity = findEventOrThrow(eventId); + boolean isCreator = requesterId.equals(entity.getCreatorId()); + if (!isAdmin && !isCreator && !isAttendee(eventId, requesterId)) { + throw new ForbiddenException("Access denied"); + } + return toEvent(entity); + } + + @Transactional + public Event updateEventDetails(UUID eventId, EventPartialUpdate body, UUID requesterId, boolean isAdmin) { + EventEntity entity = findEventOrThrow(eventId); + if (!isAdmin && !requesterId.equals(entity.getCreatorId())) { + throw new ForbiddenException("Access denied"); + } + + if (body.getName() != null) { + entity.setName(body.getName()); + } + if (body.getDescription() != null) { + entity.setDescription(body.getDescription()); + } + if (body.getStartTime() != null) { + entity.setStartTime(body.getStartTime().toInstant()); + } + if (body.getEndTime() != null) { + entity.setEndTime(body.getEndTime().toInstant()); + } + + return toEvent(eventRepository.save(entity)); + } + + @Transactional + public void deleteEvent(UUID eventId, UUID requesterId, boolean isAdmin) { + EventEntity entity = findEventOrThrow(eventId); + if (!isAdmin && !requesterId.equals(entity.getCreatorId())) { + throw new ForbiddenException("Access denied"); + } + attendanceRepository.deleteAllById_EventId(eventId); + sportEventRepository.deleteAllById_EventId(eventId); + teamEventRepository.deleteAllById_EventId(eventId); + eventRepository.delete(entity); + } + + private void persistLinks(UUID eventId, List attendees, List sports, List teams) { + if (attendees != null) { + for (String attendee : attendees) { + UUID memberId = parseUuid(attendee, "attendees"); + attendanceRepository.save(new AttendanceEntity(new AttendanceEntity.Id(eventId, memberId))); + } + } + if (sports != null) { + for (String sport : sports) { + sportEventRepository.save(new SportEventEntity(new SportEventEntity.Id(eventId, sport))); + } + } + if (teams != null) { + for (String team : teams) { + UUID teamId = parseUuid(team, "teams_linked"); + teamEventRepository.save(new TeamEventEntity(new TeamEventEntity.Id(eventId, teamId))); + } + } + } + + private boolean isAttendee(UUID eventId, UUID requesterId) { + return attendanceRepository.findAllById_EventId(eventId).stream() + .anyMatch(a -> requesterId.equals(a.getId().getMemberId())); + } + + private EventEntity findEventOrThrow(UUID eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new NotFoundException("Event not found: " + eventId)); + } + + private UUID parseUuid(String value, String fieldName) { + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ex) { + throw new BadRequestException("Invalid UUID for '" + fieldName + "': " + value); + } + } + + private Event toEvent(EventEntity entity) { + UUID eventId = entity.getId(); + return EventConverter.toEvent( + entity, + attendanceRepository.findAllById_EventId(eventId), + sportEventRepository.findAllById_EventId(eventId), + teamEventRepository.findAllById_EventId(eventId) + ); + } +} diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/EventServiceApplicationTests.java b/services/spring-event/src/test/java/tum/devoops/eventservice/EventServiceApplicationTests.java index f50e2cb..7a2b9d3 100644 --- a/services/spring-event/src/test/java/tum/devoops/eventservice/EventServiceApplicationTests.java +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/EventServiceApplicationTests.java @@ -2,7 +2,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import tum.devoops.eventservice.service.EventService; /** * Context-load smoke test. @@ -20,6 +24,12 @@ }) class EventServiceApplicationTests { + @MockitoBean + private EventService eventService; + + @MockitoBean + private JwtDecoder jwtDecoder; + @Test void contextLoads() { } diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java new file mode 100644 index 0000000..a23b445 --- /dev/null +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/EventControllerTest.java @@ -0,0 +1,336 @@ +package tum.devoops.eventservice.controller; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import tum.devoops.eventservice.config.SecurityConfig; +import tum.devoops.eventservice.exception.BadRequestException; +import tum.devoops.eventservice.exception.ForbiddenException; +import tum.devoops.eventservice.exception.NotFoundException; +import tum.devoops.eventservice.model.Event; +import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.service.EventService; + +@WebMvcTest(EventController.class) +@Import(SecurityConfig.class) +class EventControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private EventService eventService; + + private static final UUID REQUESTER_ID = UUID.randomUUID(); + private static final UUID EVENT_ID = UUID.randomUUID(); + + private Event sampleEvent() { + return new Event(EVENT_ID, "Training session", "Weekly practice", + OffsetDateTime.parse("2026-07-01T10:00:00Z"), + OffsetDateTime.parse("2026-07-01T12:00:00Z"), + REQUESTER_ID.toString()); + } + + private EventSummary sampleSummary() { + return new EventSummary(EVENT_ID, "Training session", + OffsetDateTime.parse("2026-07-01T10:00:00Z"), + OffsetDateTime.parse("2026-07-01T12:00:00Z")); + } + + private String eventCreateJson(String name) { + return "{\"name\":\"" + name + "\"," + + "\"start_time\":\"2026-07-01T10:00:00Z\"," + + "\"end_time\":\"2026-07-01T12:00:00Z\"}"; + } + + private static RequestPostProcessor memberJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_member")); + } + + private static RequestPostProcessor adminJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_admin")); + } + + private static RequestPostProcessor trainerJwt() { + return jwt().jwt(j -> j.subject(REQUESTER_ID.toString())) + .authorities(new SimpleGrantedAuthority("ROLE_trainer")); + } + + // ─── GET /events ────────────────────────────────────────────────────────── + + @Test + void getAllEventsWithoutAuthReturns401() throws Exception { + mockMvc.perform(get("/events")) + .andExpect(status().isUnauthorized()); + } + + @Test + void getAllEventsWithAuthReturns200AndList() throws Exception { + when(eventService.getAllEvents(REQUESTER_ID, false)).thenReturn(List.of(sampleSummary())); + + mockMvc.perform(get("/events") + .with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").exists()) + .andExpect(jsonPath("$[0].name").exists()) + .andExpect(jsonPath("$[0].start_time").exists()) + .andExpect(jsonPath("$[0].end_time").exists()); + } + + @Test + void getAllEventsAsAdminPassesIsAdminTrue() throws Exception { + when(eventService.getAllEvents(any(), eq(true))).thenReturn(List.of()); + + mockMvc.perform(get("/events") + .with(adminJwt())) + .andExpect(status().isOk()); + + verify(eventService).getAllEvents(REQUESTER_ID, true); + } + + // ─── POST /events ───────────────────────────────────────────────────────── + + @Test + void createEventWithoutAuthReturns401() throws Exception { + mockMvc.perform(post("/events") + .contentType(MediaType.APPLICATION_JSON) + .content(eventCreateJson("Training session"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void createEventWithAuthReturns201AndBody() throws Exception { + when(eventService.createEvent(any(), eq(REQUESTER_ID), eq(false))).thenReturn(sampleEvent()); + + mockMvc.perform(post("/events") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(eventCreateJson("Training session"))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.name").exists()) + .andExpect(jsonPath("$.creator").exists()) + .andExpect(jsonPath("$.start_time").exists()) + .andExpect(jsonPath("$.end_time").exists()); + } + + @Test + void createEventServiceThrowsForbiddenReturns403() throws Exception { + when(eventService.createEvent(any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(post("/events") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(eventCreateJson("x"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void createEventServiceThrowsBadRequestReturns400() throws Exception { + when(eventService.createEvent(any(), any(), anyBoolean())) + .thenThrow(new BadRequestException("Invalid event")); + + mockMvc.perform(post("/events") + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(eventCreateJson("x"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); + } + + // ─── GET /events/{id} ───────────────────────────────────────────────────── + + @Test + void getEventDetailsWithoutAuthReturns401() throws Exception { + mockMvc.perform(get("/events/{id}", EVENT_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void getEventDetailsWithAuthReturns200AndBody() throws Exception { + when(eventService.getEventDetails(EVENT_ID, REQUESTER_ID, false)).thenReturn(sampleEvent()); + + mockMvc.perform(get("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.name").exists()); + } + + @Test + void getEventDetailsServiceThrowsNotFoundReturns404() throws Exception { + when(eventService.getEventDetails(any(), any(), anyBoolean())) + .thenThrow(new NotFoundException("Not found")); + + mockMvc.perform(get("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void getEventDetailsServiceThrowsForbiddenReturns403() throws Exception { + when(eventService.getEventDetails(any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(get("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + } + + // ─── PATCH /events/{id} ─────────────────────────────────────────────────── + + @Test + void updateEventDetailsWithoutAuthReturns401() throws Exception { + mockMvc.perform(patch("/events/{id}", EVENT_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"updated\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void updateEventDetailsWithAuthReturns200AndBody() throws Exception { + when(eventService.updateEventDetails(eq(EVENT_ID), any(), eq(REQUESTER_ID), eq(false))) + .thenReturn(sampleEvent()); + + mockMvc.perform(patch("/events/{id}", EVENT_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"updated\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").exists()); + } + + @Test + void updateEventDetailsServiceThrowsForbiddenReturns403() throws Exception { + when(eventService.updateEventDetails(any(), any(), any(), anyBoolean())) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(patch("/events/{id}", EVENT_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"x\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void updateEventDetailsServiceThrowsNotFoundReturns404() throws Exception { + when(eventService.updateEventDetails(any(), any(), any(), anyBoolean())) + .thenThrow(new NotFoundException("Not found")); + + mockMvc.perform(patch("/events/{id}", EVENT_ID) + .with(memberJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"x\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").exists()); + } + + // ─── DELETE /events/{id} ────────────────────────────────────────────────── + + @Test + void deleteEventWithoutAuthReturns401() throws Exception { + mockMvc.perform(delete("/events/{id}", EVENT_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + void deleteEventWithAuthReturns204() throws Exception { + mockMvc.perform(delete("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteEventServiceThrowsForbiddenReturns403() throws Exception { + doThrow(new ForbiddenException("Access denied")) + .when(eventService).deleteEvent(any(), any(), anyBoolean()); + + mockMvc.perform(delete("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void deleteEventServiceThrowsNotFoundReturns404() throws Exception { + doThrow(new NotFoundException("Not found")) + .when(eventService).deleteEvent(any(), any(), anyBoolean()); + + mockMvc.perform(delete("/events/{id}", EVENT_ID) + .with(memberJwt())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").exists()); + } + + // ─── @PreAuthorize role checks ────────────────────────────────────────────── + + @Test + void getAllEventsWithWrongRoleReturns403() throws Exception { + mockMvc.perform(get("/events") + .with(trainerJwt())) + .andExpect(status().isForbidden()); + } + + @Test + void createEventWithWrongRoleReturns403() throws Exception { + mockMvc.perform(post("/events") + .with(trainerJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(eventCreateJson("x"))) + .andExpect(status().isForbidden()); + } + + @Test + void getEventDetailsWithWrongRoleReturns403() throws Exception { + mockMvc.perform(get("/events/{id}", EVENT_ID) + .with(trainerJwt())) + .andExpect(status().isForbidden()); + } + + @Test + void updateEventDetailsWithWrongRoleReturns403() throws Exception { + mockMvc.perform(patch("/events/{id}", EVENT_ID) + .with(trainerJwt()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void deleteEventWithWrongRoleReturns403() throws Exception { + mockMvc.perform(delete("/events/{id}", EVENT_ID) + .with(trainerJwt())) + .andExpect(status().isForbidden()); + } +} diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/HelloControllerTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java similarity index 97% rename from services/spring-event/src/test/java/tum/devoops/eventservice/HelloControllerTest.java rename to services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java index 9a5efcf..55382eb 100644 --- a/services/spring-event/src/test/java/tum/devoops/eventservice/HelloControllerTest.java +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/controller/HelloControllerTest.java @@ -1,4 +1,4 @@ -package tum.devoops.eventservice; +package tum.devoops.eventservice.controller; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; From 852cf8f9d9220512da240ade3695e7b4c791478f Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Fri, 26 Jun 2026 17:52:32 +0200 Subject: [PATCH 2/3] fix event endpoints and add service tests --- .../model/EventPartialUpdate.java | 12 +- .../eventservice/service/EventService.java | 96 +++-- .../service/EventServiceTest.java | 380 ++++++++++++++++++ 3 files changed, 457 insertions(+), 31 deletions(-) create mode 100644 services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java diff --git a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java index 1546b4e..3f7adc5 100644 --- a/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java +++ b/services/spring-event/src/generated/java/tum/devoops/eventservice/model/EventPartialUpdate.java @@ -38,13 +38,13 @@ public class EventPartialUpdate { private @Nullable OffsetDateTime endTime; @Valid - private List attendees = new ArrayList<>(); + private @Nullable List attendees; @Valid - private List sportsLinked = new ArrayList<>(); + private @Nullable List sportsLinked; @Valid - private List teamsLinked = new ArrayList<>(); + private @Nullable List teamsLinked; public EventPartialUpdate name(@Nullable String name) { this.name = name; @@ -146,7 +146,7 @@ public EventPartialUpdate addAttendeesItem(String attendeesItem) { @Schema(name = "attendees", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("attendees") - public List getAttendees() { + public @Nullable List getAttendees() { return attendees; } @@ -174,7 +174,7 @@ public EventPartialUpdate addSportsLinkedItem(String sportsLinkedItem) { @Schema(name = "sports_linked", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("sports_linked") - public List getSportsLinked() { + public @Nullable List getSportsLinked() { return sportsLinked; } @@ -202,7 +202,7 @@ public EventPartialUpdate addTeamsLinkedItem(String teamsLinkedItem) { @Schema(name = "teams_linked", requiredMode = Schema.RequiredMode.NOT_REQUIRED) @JsonProperty("teams_linked") - public List getTeamsLinked() { + public @Nullable List getTeamsLinked() { return teamsLinked; } diff --git a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java index b9356cb..cfac1b7 100644 --- a/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java +++ b/services/spring-event/src/main/java/tum/devoops/eventservice/service/EventService.java @@ -1,9 +1,8 @@ package tum.devoops.eventservice.service; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -46,17 +45,22 @@ public List getAllEvents(UUID requesterId, boolean isAdmin) { if (isAdmin) { entities = eventRepository.findAll(); } else { - Map byId = new LinkedHashMap<>(); - for (EventEntity entity : eventRepository.findAllByCreatorId(requesterId)) { - byId.putIfAbsent(entity.getId(), entity); - } - for (AttendanceEntity attendance : attendanceRepository.findAllById_MemberId(requesterId)) { - UUID eventId = attendance.getId().getEventId(); - if (!byId.containsKey(eventId)) { - eventRepository.findById(eventId).ifPresent(e -> byId.put(eventId, e)); - } - } - entities = new ArrayList<>(byId.values()); + List created = eventRepository.findAllByCreatorId(requesterId); + Set createdIds = created.stream() + .map(EventEntity::getId) + .collect(Collectors.toSet()); + + List attendedIds = attendanceRepository.findAllById_MemberId(requesterId).stream() + .map(a -> a.getId().getEventId()) + .filter(id -> !createdIds.contains(id)) + .collect(Collectors.toList()); + + List attended = attendedIds.isEmpty() + ? List.of() + : eventRepository.findAllById(attendedIds); + + entities = new ArrayList<>(created); + entities.addAll(attended); } return entities.stream().map(EventConverter::toSummary).collect(Collectors.toList()); } @@ -111,6 +115,25 @@ public Event updateEventDetails(UUID eventId, EventPartialUpdate body, UUID requ entity.setEndTime(body.getEndTime().toInstant()); } + if ((body.getStartTime() != null || body.getEndTime() != null) + && !entity.getEndTime().isAfter(entity.getStartTime())) { + throw new BadRequestException("Event end time must be after start time"); + } + + // null means the field was omitted (no change); non-null (including empty) means replace + if (body.getAttendees() != null) { + attendanceRepository.deleteAllById_EventId(eventId); + attendanceRepository.saveAll(buildAttendanceEntities(eventId, body.getAttendees())); + } + if (body.getSportsLinked() != null) { + sportEventRepository.deleteAllById_EventId(eventId); + sportEventRepository.saveAll(buildSportEntities(eventId, body.getSportsLinked())); + } + if (body.getTeamsLinked() != null) { + teamEventRepository.deleteAllById_EventId(eventId); + teamEventRepository.saveAll(buildTeamEntities(eventId, body.getTeamsLinked())); + } + return toEvent(eventRepository.save(entity)); } @@ -128,27 +151,47 @@ public void deleteEvent(UUID eventId, UUID requesterId, boolean isAdmin) { private void persistLinks(UUID eventId, List attendees, List sports, List teams) { if (attendees != null) { - for (String attendee : attendees) { - UUID memberId = parseUuid(attendee, "attendees"); - attendanceRepository.save(new AttendanceEntity(new AttendanceEntity.Id(eventId, memberId))); - } + attendanceRepository.saveAll(buildAttendanceEntities(eventId, attendees)); } if (sports != null) { - for (String sport : sports) { - sportEventRepository.save(new SportEventEntity(new SportEventEntity.Id(eventId, sport))); - } + sportEventRepository.saveAll(buildSportEntities(eventId, sports)); } if (teams != null) { - for (String team : teams) { - UUID teamId = parseUuid(team, "teams_linked"); - teamEventRepository.save(new TeamEventEntity(new TeamEventEntity.Id(eventId, teamId))); + teamEventRepository.saveAll(buildTeamEntities(eventId, teams)); + } + } + + private List buildAttendanceEntities(UUID eventId, List attendees) { + List result = new ArrayList<>(); + for (String attendee : attendees) { + UUID memberId = parseUuid(attendee, "attendees"); + result.add(new AttendanceEntity(new AttendanceEntity.Id(eventId, memberId))); + } + return result; + } + + private List buildSportEntities(UUID eventId, List sports) { + List result = new ArrayList<>(); + for (String sport : sports) { + if (sport == null) { + throw new BadRequestException("'sports_linked' contains a null entry"); } + result.add(new SportEventEntity(new SportEventEntity.Id(eventId, sport))); } + return result; + } + + private List buildTeamEntities(UUID eventId, List teams) { + List result = new ArrayList<>(); + for (String team : teams) { + UUID teamId = parseUuid(team, "teams_linked"); + result.add(new TeamEventEntity(new TeamEventEntity.Id(eventId, teamId))); + } + return result; } private boolean isAttendee(UUID eventId, UUID requesterId) { - return attendanceRepository.findAllById_EventId(eventId).stream() - .anyMatch(a -> requesterId.equals(a.getId().getMemberId())); + return attendanceRepository.existsById(new AttendanceEntity.Id(eventId, requesterId)); } private EventEntity findEventOrThrow(UUID eventId) { @@ -157,6 +200,9 @@ private EventEntity findEventOrThrow(UUID eventId) { } private UUID parseUuid(String value, String fieldName) { + if (value == null) { + throw new BadRequestException("'" + fieldName + "' contains a null entry"); + } try { return UUID.fromString(value); } catch (IllegalArgumentException ex) { diff --git a/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java new file mode 100644 index 0000000..d86f629 --- /dev/null +++ b/services/spring-event/src/test/java/tum/devoops/eventservice/service/EventServiceTest.java @@ -0,0 +1,380 @@ +package tum.devoops.eventservice.service; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import org.mockito.junit.jupiter.MockitoExtension; + +import tum.devoops.eventservice.entity.AttendanceEntity; +import tum.devoops.eventservice.entity.EventEntity; +import tum.devoops.eventservice.entity.SportEventEntity; +import tum.devoops.eventservice.entity.TeamEventEntity; +import tum.devoops.eventservice.exception.BadRequestException; +import tum.devoops.eventservice.exception.ForbiddenException; +import tum.devoops.eventservice.exception.NotFoundException; +import tum.devoops.eventservice.model.Event; +import tum.devoops.eventservice.model.EventCreate; +import tum.devoops.eventservice.model.EventPartialUpdate; +import tum.devoops.eventservice.model.EventSummary; +import tum.devoops.eventservice.repository.AttendanceRepository; +import tum.devoops.eventservice.repository.EventRepository; +import tum.devoops.eventservice.repository.SportEventRepository; +import tum.devoops.eventservice.repository.TeamEventRepository; + +@ExtendWith(MockitoExtension.class) +class EventServiceTest { + + @Mock + private EventRepository eventRepository; + @Mock + private AttendanceRepository attendanceRepository; + @Mock + private SportEventRepository sportEventRepository; + @Mock + private TeamEventRepository teamEventRepository; + + @InjectMocks + private EventService service; + + private static final UUID REQUESTER_ID = UUID.randomUUID(); + private static final UUID OTHER_ID = UUID.randomUUID(); + private static final UUID EVENT_ID = UUID.randomUUID(); + private static final UUID MEMBER_ID = UUID.randomUUID(); + private static final UUID TEAM_ID = UUID.randomUUID(); + + private static final Instant START = Instant.parse("2026-01-01T10:00:00Z"); + private static final Instant END = Instant.parse("2026-01-01T12:00:00Z"); + + private EventEntity eventEntity(UUID id, UUID creatorId) { + EventEntity e = new EventEntity(); + e.setId(id); + e.setName("Test Event"); + e.setDescription("desc"); + e.setStartTime(START); + e.setEndTime(END); + e.setCreatorId(creatorId); + return e; + } + + private static OffsetDateTime odt(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private EventCreate validCreate() { + return new EventCreate("Test Event", odt(START), odt(END)).description("desc"); + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> listCaptor() { + return ArgumentCaptor.forClass(List.class); + } + + // ─── getAllEvents ────────────────────────────────────────────────────────── + + @Test + void getAllEventsAsAdminReturnsAllAndIgnoresAttendance() { + EventEntity a = eventEntity(UUID.randomUUID(), REQUESTER_ID); + EventEntity b = eventEntity(UUID.randomUUID(), OTHER_ID); + when(eventRepository.findAll()).thenReturn(List.of(a, b)); + + List result = service.getAllEvents(REQUESTER_ID, true); + + assertThat(result).hasSize(2); + verifyNoInteractions(attendanceRepository); + } + + @Test + void getAllEventsAsNonAdminUnionsCreatedAndAttended() { + UUID attendedId = UUID.randomUUID(); + EventEntity created = eventEntity(EVENT_ID, REQUESTER_ID); + EventEntity attended = eventEntity(attendedId, OTHER_ID); + when(eventRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(List.of(created)); + when(attendanceRepository.findAllById_MemberId(REQUESTER_ID)) + .thenReturn(List.of(new AttendanceEntity(new AttendanceEntity.Id(attendedId, REQUESTER_ID)))); + when(eventRepository.findAllById(List.of(attendedId))).thenReturn(List.of(attended)); + + List result = service.getAllEvents(REQUESTER_ID, false); + + assertThat(result).extracting(EventSummary::getId) + .containsExactlyInAnyOrder(EVENT_ID, attendedId); + } + + @Test + void getAllEventsAsNonAdminDeduplicatesCreatedAndAttended() { + EventEntity created = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findAllByCreatorId(REQUESTER_ID)).thenReturn(List.of(created)); + when(attendanceRepository.findAllById_MemberId(REQUESTER_ID)) + .thenReturn(List.of(new AttendanceEntity(new AttendanceEntity.Id(EVENT_ID, REQUESTER_ID)))); + + List result = service.getAllEvents(REQUESTER_ID, false); + + assertThat(result).hasSize(1); + // Already-created event is filtered out, so no batch fetch is issued. + verify(eventRepository, never()).findAllById(any()); + } + + // ─── createEvent ─────────────────────────────────────────────────────────── + + @Test + void createEventWithEndBeforeStartThrowsBadRequest() { + EventCreate body = new EventCreate("x", odt(END), odt(START)); + + assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, true)) + .isInstanceOf(BadRequestException.class); + verify(eventRepository, never()).save(any()); + } + + @Test + void createEventWithNullTimesThrowsBadRequest() { + EventCreate body = new EventCreate("x", null, null); + + assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, true)) + .isInstanceOf(BadRequestException.class); + verify(eventRepository, never()).save(any()); + } + + @Test + void createEventPersistsEventAndLinks() { + EventEntity saved = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.save(any())).thenReturn(saved); + EventCreate body = validCreate() + .attendees(List.of(MEMBER_ID.toString())) + .sportsLinked(List.of("football")) + .teamsLinked(List.of(TEAM_ID.toString())); + + Event result = service.createEvent(body, REQUESTER_ID, true); + + assertThat(result.getCreator()).isEqualTo(REQUESTER_ID.toString()); + + ArgumentCaptor> attendees = listCaptor(); + verify(attendanceRepository).saveAll(attendees.capture()); + assertThat(attendees.getValue()).extracting(a -> a.getId().getMemberId()).containsExactly(MEMBER_ID); + + ArgumentCaptor> sports = listCaptor(); + verify(sportEventRepository).saveAll(sports.capture()); + assertThat(sports.getValue()).extracting(s -> s.getId().getSportName()).containsExactly("football"); + + ArgumentCaptor> teams = listCaptor(); + verify(teamEventRepository).saveAll(teams.capture()); + assertThat(teams.getValue()).extracting(t -> t.getId().getTeamId()).containsExactly(TEAM_ID); + } + + @Test + void createEventWithInvalidAttendeeUuidThrowsBadRequest() { + when(eventRepository.save(any())).thenReturn(eventEntity(EVENT_ID, REQUESTER_ID)); + EventCreate body = validCreate().attendees(List.of("not-a-uuid")); + + assertThatThrownBy(() -> service.createEvent(body, REQUESTER_ID, true)) + .isInstanceOf(BadRequestException.class); + } + + // ─── getEventDetails ─────────────────────────────────────────────────────── + + @Test + void getEventDetailsNotFoundThrowsNotFound() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getEventDetails(EVENT_ID, REQUESTER_ID, false)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void getEventDetailsAsUnrelatedUserThrowsForbidden() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, OTHER_ID))); + when(attendanceRepository.existsById(new AttendanceEntity.Id(EVENT_ID, REQUESTER_ID))).thenReturn(false); + + assertThatThrownBy(() -> service.getEventDetails(EVENT_ID, REQUESTER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void getEventDetailsAsCreatorSucceeds() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, REQUESTER_ID))); + + Event result = service.getEventDetails(EVENT_ID, REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + + @Test + void getEventDetailsAsAttendeeSucceeds() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, OTHER_ID))); + when(attendanceRepository.existsById(new AttendanceEntity.Id(EVENT_ID, REQUESTER_ID))).thenReturn(true); + + Event result = service.getEventDetails(EVENT_ID, REQUESTER_ID, false); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + + @Test + void getEventDetailsAsAdminSucceeds() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, OTHER_ID))); + + Event result = service.getEventDetails(EVENT_ID, REQUESTER_ID, true); + + assertThat(result.getId()).isEqualTo(EVENT_ID); + } + + // ─── updateEventDetails ────────────────────────────────────────────────────── + + @Test + void updateEventDetailsNotFoundThrowsNotFound() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateEventDetails( + EVENT_ID, new EventPartialUpdate(), REQUESTER_ID, true)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void updateEventDetailsAsNonCreatorThrowsForbidden() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, OTHER_ID))); + + assertThatThrownBy(() -> service.updateEventDetails( + EVENT_ID, new EventPartialUpdate().name("x"), REQUESTER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void updateEventDetailsAppliesScalarFieldsOnlyWhenPresent() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + ArgumentCaptor captor = ArgumentCaptor.forClass(EventEntity.class); + when(eventRepository.save(captor.capture())).thenReturn(entity); + + service.updateEventDetails(EVENT_ID, new EventPartialUpdate().name("Renamed"), REQUESTER_ID, false); + + assertThat(captor.getValue().getName()).isEqualTo("Renamed"); + assertThat(captor.getValue().getDescription()).isEqualTo("desc"); + } + + @Test + void updateEventDetailsWithEndBeforeStartThrowsBadRequest() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, REQUESTER_ID))); + // Push start past the existing end time. + EventPartialUpdate body = new EventPartialUpdate().startTime(odt(END.plusSeconds(3600))); + + assertThatThrownBy(() -> service.updateEventDetails(EVENT_ID, body, REQUESTER_ID, false)) + .isInstanceOf(BadRequestException.class); + verify(eventRepository, never()).save(any()); + } + + @Test + void updateEventDetailsWithValidTimePatchSucceeds() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + when(eventRepository.save(any())).thenReturn(entity); + EventPartialUpdate body = new EventPartialUpdate().startTime(odt(START.plusSeconds(1800))); + + Event result = service.updateEventDetails(EVENT_ID, body, REQUESTER_ID, false); + + assertThat(result).isNotNull(); + } + + @Test + void updateEventDetailsWithNullListsLeavesLinksUntouched() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + when(eventRepository.save(any())).thenReturn(entity); + + service.updateEventDetails(EVENT_ID, new EventPartialUpdate().name("x"), REQUESTER_ID, false); + + verify(attendanceRepository, never()).deleteAllById_EventId(any()); + verify(attendanceRepository, never()).saveAll(any()); + verify(sportEventRepository, never()).deleteAllById_EventId(any()); + verify(teamEventRepository, never()).deleteAllById_EventId(any()); + } + + @Test + void updateEventDetailsWithEmptyListClearsLinks() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + when(eventRepository.save(any())).thenReturn(entity); + + service.updateEventDetails( + EVENT_ID, new EventPartialUpdate().attendees(List.of()), REQUESTER_ID, false); + + verify(attendanceRepository).deleteAllById_EventId(EVENT_ID); + ArgumentCaptor> captor = listCaptor(); + verify(attendanceRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).isEmpty(); + } + + @Test + void updateEventDetailsWithPopulatedListReplacesLinks() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + when(eventRepository.save(any())).thenReturn(entity); + + service.updateEventDetails( + EVENT_ID, new EventPartialUpdate().teamsLinked(List.of(TEAM_ID.toString())), REQUESTER_ID, false); + + verify(teamEventRepository).deleteAllById_EventId(EVENT_ID); + ArgumentCaptor> captor = listCaptor(); + verify(teamEventRepository).saveAll(captor.capture()); + assertThat(captor.getValue()).extracting(t -> t.getId().getTeamId()).containsExactly(TEAM_ID); + // Untouched link types are left alone. + verify(attendanceRepository, never()).deleteAllById_EventId(any()); + verify(sportEventRepository, never()).deleteAllById_EventId(any()); + } + + @Test + void updateEventDetailsWithNullSportEntryThrowsBadRequest() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + List sports = new ArrayList<>(); + sports.add(null); + + assertThatThrownBy(() -> service.updateEventDetails( + EVENT_ID, new EventPartialUpdate().sportsLinked(sports), REQUESTER_ID, false)) + .isInstanceOf(BadRequestException.class); + } + + // ─── deleteEvent ───────────────────────────────────────────────────────────── + + @Test + void deleteEventNotFoundThrowsNotFound() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteEvent(EVENT_ID, REQUESTER_ID, false)) + .isInstanceOf(NotFoundException.class); + } + + @Test + void deleteEventAsNonCreatorThrowsForbidden() { + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(eventEntity(EVENT_ID, OTHER_ID))); + + assertThatThrownBy(() -> service.deleteEvent(EVENT_ID, REQUESTER_ID, false)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + void deleteEventAsCreatorRemovesEventAndLinks() { + EventEntity entity = eventEntity(EVENT_ID, REQUESTER_ID); + when(eventRepository.findById(EVENT_ID)).thenReturn(Optional.of(entity)); + + service.deleteEvent(EVENT_ID, REQUESTER_ID, false); + + verify(attendanceRepository).deleteAllById_EventId(EVENT_ID); + verify(sportEventRepository).deleteAllById_EventId(EVENT_ID); + verify(teamEventRepository).deleteAllById_EventId(EVENT_ID); + verify(eventRepository).delete(entity); + } +} From 9139671d8340995729816bffa014afcc3d9e1ed7 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Sat, 27 Jun 2026 09:14:33 +0200 Subject: [PATCH 3/3] fix env variables for deployment --- infra/helm/team-devoops/values.yaml | 3 ++ .../src/main/resources/application.properties | 5 +++ .../MemberServiceApplicationTests.java | 43 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index d576cdb..9e6089e 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -181,6 +181,9 @@ services: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" JAVA_TOOL_OPTIONS: "-Xmx300m -Xms64m" + # Keycloak Admin REST API for user provisioning (KeycloakService). + KEYCLOAK_BASE_URL: "http://keycloak:8080/auth" + KEYCLOAK_REALM: "devops" event-service: path: /api/v1/events port: 8080 diff --git a/services/spring-member/src/main/resources/application.properties b/services/spring-member/src/main/resources/application.properties index 68f33fc..8603de9 100644 --- a/services/spring-member/src/main/resources/application.properties +++ b/services/spring-member/src/main/resources/application.properties @@ -9,3 +9,8 @@ spring.jpa.properties.hibernate.default_schema=member spring.flyway.default-schema=member spring.flyway.schemas=member spring.flyway.create-schemas=true + +# Keycloak Admin REST API used for user provisioning. Same service name/path in +# docker-compose and the cluster; override per-env via KEYCLOAK_BASE_URL / KEYCLOAK_REALM. +keycloak.base-url=http://keycloak:8080/auth +keycloak.realm=devops diff --git a/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java new file mode 100644 index 0000000..1b4e4e6 --- /dev/null +++ b/services/spring-member/src/test/java/tum/devoops/memberservice/MemberServiceApplicationTests.java @@ -0,0 +1,43 @@ +package tum.devoops.memberservice; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import tum.devoops.memberservice.service.MemberService; + +/** + * Context-load smoke test. + * + * DataSource and JPA auto-configurations are excluded so the test can run + * without a live PostgreSQL instance. + * + * MemberService is mocked (it depends on the JPA repository, which is not + * created without a DataSource), but KeycloakService is deliberately left real: + * its constructor resolves the {@code keycloak.base-url} / {@code keycloak.realm} + * placeholders at startup, so this test fails fast if that config is missing — + * which is exactly the deployment failure this guards against. + */ +@SpringBootTest(properties = { + "spring.autoconfigure.exclude=" + + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," + + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration" +}) +@TestPropertySource(properties = { + "spring.jpa.hibernate.ddl-auto=none" +}) +class MemberServiceApplicationTests { + + @MockitoBean + private MemberService memberService; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @Test + void contextLoads() { + } + +}