diff --git a/backend/src/main/java/de/fete/adapter/in/web/EventController.java b/backend/src/main/java/de/fete/adapter/in/web/EventController.java new file mode 100644 index 0000000..db4a463 --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/in/web/EventController.java @@ -0,0 +1,46 @@ +package de.fete.adapter.in.web; + +import de.fete.adapter.in.web.api.EventsApi; +import de.fete.adapter.in.web.model.CreateEventRequest; +import de.fete.adapter.in.web.model.CreateEventResponse; +import de.fete.domain.model.CreateEventCommand; +import de.fete.domain.model.Event; +import de.fete.domain.port.in.CreateEventUseCase; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +/** REST controller for event operations. */ +@RestController +public class EventController implements EventsApi { + + private final CreateEventUseCase createEventUseCase; + + /** Creates a new controller with the given use case. */ + public EventController(CreateEventUseCase createEventUseCase) { + this.createEventUseCase = createEventUseCase; + } + + @Override + public ResponseEntity createEvent( + CreateEventRequest request) { + var command = new CreateEventCommand( + request.getTitle(), + request.getDescription(), + request.getDateTime(), + request.getLocation(), + request.getExpiryDate() + ); + + Event event = createEventUseCase.createEvent(command); + + var response = new CreateEventResponse(); + response.setEventToken(event.getEventToken()); + response.setOrganizerToken(event.getOrganizerToken()); + response.setTitle(event.getTitle()); + response.setDateTime(event.getDateTime()); + response.setExpiryDate(event.getExpiryDate()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..4ac221a --- /dev/null +++ b/backend/src/main/java/de/fete/adapter/in/web/GlobalExceptionHandler.java @@ -0,0 +1,71 @@ +package de.fete.adapter.in.web; + +import de.fete.application.service.ExpiryDateInPastException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** Global exception handler producing RFC 9457 Problem Details responses. */ +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + + @Override + protected ResponseEntity handleMethodArgumentNotValid( + MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + + ProblemDetail problemDetail = ex.getBody(); + problemDetail.setTitle("Validation Failed"); + problemDetail.setType(URI.create("urn:problem-type:validation-error")); + + List> fieldErrors = ex.getBindingResult() + .getFieldErrors() + .stream() + .map(fe -> Map.of( + "field", fe.getField(), + "message", fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "invalid" + )) + .toList(); + + problemDetail.setProperty("fieldErrors", fieldErrors); + return handleExceptionInternal(ex, problemDetail, headers, status, request); + } + + /** Handles expiry date validation failures. */ + @ExceptionHandler(ExpiryDateInPastException.class) + public ResponseEntity handleExpiryDateInPast( + ExpiryDateInPastException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, ex.getMessage()); + problemDetail.setTitle("Invalid Expiry Date"); + problemDetail.setType(URI.create("urn:problem-type:expiry-date-in-past")); + return ResponseEntity.badRequest() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } + + /** Catches all unhandled exceptions. */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleAll(Exception ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + "An unexpected error occurred."); + problemDetail.setTitle("Internal Server Error"); + return ResponseEntity.internalServerError() + .contentType(MediaType.APPLICATION_PROBLEM_JSON) + .body(problemDetail); + } +} diff --git a/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java new file mode 100644 index 0000000..6bbdfa7 --- /dev/null +++ b/backend/src/test/java/de/fete/adapter/in/web/EventControllerIntegrationTest.java @@ -0,0 +1,180 @@ +package de.fete.adapter.in.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import de.fete.TestcontainersConfig; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfig.class) +class EventControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void createEventWithValidBody() throws Exception { + String body = + """ + { + "title": "Birthday Party", + "description": "Come celebrate!", + "dateTime": "2026-06-15T20:00:00+02:00", + "location": "Berlin", + "expiryDate": "%s" + } + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.eventToken").isNotEmpty()) + .andExpect(jsonPath("$.organizerToken").isNotEmpty()) + .andExpect(jsonPath("$.title").value("Birthday Party")) + .andExpect(jsonPath("$.dateTime").isNotEmpty()) + .andExpect(jsonPath("$.expiryDate").isNotEmpty()); + } + + @Test + void createEventWithOptionalFieldsNull() throws Exception { + String body = + """ + { + "title": "Minimal Event", + "dateTime": "2026-06-15T20:00:00+02:00", + "expiryDate": "%s" + } + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.eventToken").isNotEmpty()) + .andExpect(jsonPath("$.organizerToken").isNotEmpty()) + .andExpect(jsonPath("$.title").value("Minimal Event")); + } + + @Test + void createEventMissingTitleReturns400() throws Exception { + String body = + """ + { + "dateTime": "2026-06-15T20:00:00+02:00", + "expiryDate": "%s" + } + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.title").value("Validation Failed")) + .andExpect(jsonPath("$.fieldErrors").isArray()); + } + + @Test + void createEventMissingDateTimeReturns400() throws Exception { + String body = + """ + { + "title": "No Date", + "expiryDate": "%s" + } + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.fieldErrors").isArray()); + } + + @Test + void createEventMissingExpiryDateReturns400() throws Exception { + String body = + """ + { + "title": "No Expiry", + "dateTime": "2026-06-15T20:00:00+02:00" + } + """; + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.fieldErrors").isArray()); + } + + @Test + void createEventExpiryDateInPastReturns400() throws Exception { + String body = + """ + { + "title": "Past Expiry", + "dateTime": "2026-06-15T20:00:00+02:00", + "expiryDate": "2025-01-01" + } + """; + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); + } + + @Test + void createEventExpiryDateTodayReturns400() throws Exception { + String body = + """ + { + "title": "Today Expiry", + "dateTime": "2026-06-15T20:00:00+02:00", + "expiryDate": "%s" + } + """.formatted(LocalDate.now()); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")) + .andExpect(jsonPath("$.type").value("urn:problem-type:expiry-date-in-past")); + } + + @Test + void errorResponseContentTypeIsProblemJson() throws Exception { + String body = + """ + { + "title": "", + "dateTime": "2026-06-15T20:00:00+02:00", + "expiryDate": "%s" + } + """.formatted(LocalDate.now().plusDays(30)); + + mockMvc.perform(post("/api/events") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith("application/problem+json")); + } +}