diff --git a/src/main/java/org/patinanetwork/patchats/api/match/AdminMatchController.java b/src/main/java/org/patinanetwork/patchats/api/match/AdminMatchController.java new file mode 100644 index 00000000..0fe42302 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/AdminMatchController.java @@ -0,0 +1,31 @@ +package org.patinanetwork.patchats.api.match; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.dto.match.AdminMatchResponse; +import org.patinanetwork.patchats.api.match.dto.match.CreateMatchRequest; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/matches") +@Tag(name = "Admin Matches") +@RequiredArgsConstructor +public class AdminMatchController { + + private final MatchService matchService; + + @Operation(summary = "Create a match between two members for a match cycle") + @PostMapping + public ResponseEntity> createMatch( + @Valid @RequestBody final CreateMatchRequest request) { + final AdminMatchResponse response = matchService.createMatch(request); + return ResponseEntity.ok(ApiResponder.success("Match created successfully", response)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/MatchService.java b/src/main/java/org/patinanetwork/patchats/api/match/MatchService.java new file mode 100644 index 00000000..6dc34474 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/MatchService.java @@ -0,0 +1,48 @@ +package org.patinanetwork.patchats.api.match; + +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.db.models.Match; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; +import org.patinanetwork.patchats.api.match.db.repos.MatchCycleRepo; +import org.patinanetwork.patchats.api.match.db.repos.MatchRepo; +import org.patinanetwork.patchats.api.match.dto.match.AdminMatchResponse; +import org.patinanetwork.patchats.api.match.dto.match.CreateMatchRequest; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MatchService { + + private static final String DEFAULT_MATCH_STATUS = "PENDING"; + private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + private final MatchRepo matchRepo; + private final MatchCycleRepo matchCycleRepo; + + public AdminMatchResponse createMatch(CreateMatchRequest request) { + MatchCycle cycle = matchCycleRepo + .getMatchCycleById(request.matchCycleId()) + .orElseThrow(() -> new MatchCycleNotFoundException(request.matchCycleId())); + + Match match = Match.builder() + .id(UUID.randomUUID()) + .memberAId(request.memberAId()) + .memberBId(request.memberBId()) + .matchCycleId(request.matchCycleId()) + .matchScore(request.matchScore()) + .status(request.status() == null ? DEFAULT_MATCH_STATUS : request.status()) + .build(); + + Match createdMatch = matchRepo.createMatch(match); + return AdminMatchResponse.from(createdMatch, deriveMonth(cycle)); + } + + /** Derives the "YYYY-MM" month label for a match from its cycle's run time (UTC). */ + private String deriveMonth(final MatchCycle cycle) { + return MONTH_FORMATTER.format(cycle.getRunAt().atZone(ZoneOffset.UTC)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/AdminMatchResponse.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/AdminMatchResponse.java new file mode 100644 index 00000000..494191ca --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/AdminMatchResponse.java @@ -0,0 +1,54 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.UUID; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.patinanetwork.patchats.api.match.db.models.Match; + +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class AdminMatchResponse { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID matchId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID memberAId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final UUID memberBId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Integer matchCycleId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final String month; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final String status; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private final Double matchScore; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Instant createdAt; + + public static AdminMatchResponse from(final Match match, final String month) { + return AdminMatchResponse.builder() + .matchId(match.getId()) + .memberAId(match.getMemberAId()) + .memberBId(match.getMemberBId()) + .matchCycleId(match.getMatchCycleId()) + .month(month) + .status(match.getStatus()) + .matchScore(match.getMatchScore()) + .createdAt(match.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/CreateMatchRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/CreateMatchRequest.java new file mode 100644 index 00000000..4f416cca --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/CreateMatchRequest.java @@ -0,0 +1,11 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +public record CreateMatchRequest( + @NotNull UUID memberAId, + @NotNull UUID memberBId, + @NotNull Integer matchCycleId, + Double matchScore, + String status) {} diff --git a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java index c0f0f77e..00bb7e5b 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -8,6 +8,7 @@ import org.patinanetwork.patchats.common.web.exception.EmailNotFoundException; import org.patinanetwork.patchats.common.web.exception.EmailNotResendableException; import org.patinanetwork.patchats.common.web.exception.EmailTemplateNotFoundException; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; import org.patinanetwork.patchats.common.web.exception.ValidationException; @@ -24,73 +25,78 @@ @RestControllerAdvice public class ApiExceptionHandler { - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(final MethodArgumentNotValidException ex) { - final String details = ex.getBindingResult().getFieldErrors().stream() - .map(this::formatError) - .collect(Collectors.joining("; ")); - final String message = details.isBlank() ? "Validation failed" : details; - return ResponseEntity.badRequest().body(ApiResponder.failure(message)); - } - - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity> handleParameterTypeMismatch( - final MethodArgumentTypeMismatchException ex) { - final String message = "Invalid value for query parameter '" + ex.getName() + "'"; - return ResponseEntity.badRequest().body(ApiResponder.failure(message)); - } - - @ExceptionHandler(HandlerMethodValidationException.class) - public ResponseEntity> handleParameterValidation(final HandlerMethodValidationException ex) { - return ResponseEntity.badRequest().body(ApiResponder.failure("Invalid query parameters")); - } - - @ExceptionHandler(MemberNotFoundException.class) - public ResponseEntity> handleMemberNotFound(final MemberNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(MemberDuplicateException.class) - public ResponseEntity> handleMemberDuplicate(final MemberDuplicateException ex) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(EmailTemplateNotFoundException.class) - public ResponseEntity> handleEmailTemplateNotFound(final EmailTemplateNotFoundException ex) { - return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(EmailNotFoundException.class) - public ResponseEntity> handleEmailNotFound(final EmailNotFoundException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(EmailNotResendableException.class) - public ResponseEntity> handleEmailNotResendable(final EmailNotResendableException ex) { - return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(ValidationException.class) - public ResponseEntity> handleValidation(ValidationException ex) { - return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(InvalidMagicLinkException.class) - public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) { - return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(UnregisteredEmailException.class) - public ResponseEntity> handleUnregisteredEmail(final UnregisteredEmailException ex) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); - } - - @ExceptionHandler(TooManyLinkRequestsException.class) - public ResponseEntity> handleTooManyLinkRequests(final TooManyLinkRequestsException ex) { - return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ApiResponder.failure(ex.getMessage())); - } - - private String formatError(final FieldError error) { - return error.getField() + " " + error.getDefaultMessage(); - } + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(final MethodArgumentNotValidException ex) { + final String details = ex.getBindingResult().getFieldErrors().stream() + .map(this::formatError) + .collect(Collectors.joining("; ")); + final String message = details.isBlank() ? "Validation failed" : details; + return ResponseEntity.badRequest().body(ApiResponder.failure(message)); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleParameterTypeMismatch( + final MethodArgumentTypeMismatchException ex) { + final String message = "Invalid value for query parameter '" + ex.getName() + "'"; + return ResponseEntity.badRequest().body(ApiResponder.failure(message)); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleParameterValidation(final HandlerMethodValidationException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure("Invalid query parameters")); + } + + @ExceptionHandler(MemberNotFoundException.class) + public ResponseEntity> handleMemberNotFound(final MemberNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(MemberDuplicateException.class) + public ResponseEntity> handleMemberDuplicate(final MemberDuplicateException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(EmailTemplateNotFoundException.class) + public ResponseEntity> handleEmailTemplateNotFound(final EmailTemplateNotFoundException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(EmailNotFoundException.class) + public ResponseEntity> handleEmailNotFound(final EmailNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(EmailNotResendableException.class) + public ResponseEntity> handleEmailNotResendable(final EmailNotResendableException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidation(ValidationException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(InvalidMagicLinkException.class) + public ResponseEntity> handleInvalidMagicLink(final InvalidMagicLinkException ex) { + return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(UnregisteredEmailException.class) + public ResponseEntity> handleUnregisteredEmail(final UnregisteredEmailException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(TooManyLinkRequestsException.class) + public ResponseEntity> handleTooManyLinkRequests(final TooManyLinkRequestsException ex) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(ApiResponder.failure(ex.getMessage())); + } + + @ExceptionHandler(MatchCycleNotFoundException.class) + public ResponseEntity> handleMatchCycleNotFound(final MatchCycleNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage())); + } + + private String formatError(final FieldError error) { + return error.getField() + " " + error.getDefaultMessage(); + } } diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleNotFoundException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleNotFoundException.java new file mode 100644 index 00000000..b2904396 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleNotFoundException.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.common.web.exception; + +public class MatchCycleNotFoundException extends RuntimeException { + public MatchCycleNotFoundException(Integer id) { + super("Match Cycle with ID " + id + " not found"); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/api/match/AdminMatchControllerTest.java b/src/test/java/org/patinanetwork/patchats/api/match/AdminMatchControllerTest.java new file mode 100644 index 00000000..ee5c2ffe --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/api/match/AdminMatchControllerTest.java @@ -0,0 +1,88 @@ +package org.patinanetwork.patchats.api.match; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.Instant; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.api.match.dto.match.AdminMatchResponse; +import org.patinanetwork.patchats.common.web.ApiExceptionHandler; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(AdminMatchController.class) +@AutoConfigureMockMvc(addFilters = false) +@Import(ApiExceptionHandler.class) +class AdminMatchControllerTest { + + private static final String MEMBER_A_ID = "11111111-1111-1111-1111-111111111111"; + private static final String MEMBER_B_ID = "22222222-2222-2222-2222-222222222222"; + private static final String REQUEST_BODY = + "{\"memberAId\":\"%s\",\"memberBId\":\"%s\",\"matchCycleId\":1,\"matchScore\":0.85,\"status\":\"CONFIRMED\"}" + .formatted(MEMBER_A_ID, MEMBER_B_ID); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private MatchService matchService; + + @Test + void createMatch_ReturnsOkAndAdminMatchResponse() throws Exception { + when(matchService.createMatch(any())) + .thenReturn(AdminMatchResponse.builder() + .matchId(UUID.fromString("33333333-3333-3333-3333-333333333333")) + .memberAId(UUID.fromString(MEMBER_A_ID)) + .memberBId(UUID.fromString(MEMBER_B_ID)) + .matchCycleId(1) + .month("2026-07") + .status("CONFIRMED") + .matchScore(0.85) + .createdAt(Instant.parse("2026-07-15T10:00:00Z")) + .build()); + + mockMvc.perform(post("/api/admin/matches") + .contentType(MediaType.APPLICATION_JSON) + .content(REQUEST_BODY)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.payload.matchId").value("33333333-3333-3333-3333-333333333333")) + .andExpect(jsonPath("$.payload.memberAId").value(MEMBER_A_ID)) + .andExpect(jsonPath("$.payload.memberBId").value(MEMBER_B_ID)) + .andExpect(jsonPath("$.payload.matchCycleId").value(1)) + .andExpect(jsonPath("$.payload.month").value("2026-07")) + .andExpect(jsonPath("$.payload.status").value("CONFIRMED")) + .andExpect(jsonPath("$.payload.matchScore").value(0.85)) + .andExpect(jsonPath("$.payload.createdAt").isNotEmpty()); + } + + @Test + void createMatch_ReturnsBadRequestWhenMemberAIdMissing() throws Exception { + mockMvc.perform(post("/api/admin/matches") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberBId\":\"%s\",\"matchCycleId\":1}".formatted(MEMBER_B_ID))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + void createMatch_ReturnsNotFoundWhenCycleMissing() throws Exception { + when(matchService.createMatch(any())).thenThrow(new MatchCycleNotFoundException(1)); + + mockMvc.perform(post("/api/admin/matches") + .contentType(MediaType.APPLICATION_JSON) + .content(REQUEST_BODY)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/api/match/MatchServiceTest.java b/src/test/java/org/patinanetwork/patchats/api/match/MatchServiceTest.java new file mode 100644 index 00000000..f234415f --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/api/match/MatchServiceTest.java @@ -0,0 +1,74 @@ +package org.patinanetwork.patchats.api.match; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; +import org.patinanetwork.patchats.api.match.db.repos.MatchCycleRepo; +import org.patinanetwork.patchats.api.match.db.repos.MatchRepo; +import org.patinanetwork.patchats.api.match.dto.match.AdminMatchResponse; +import org.patinanetwork.patchats.api.match.dto.match.CreateMatchRequest; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; + +class MatchServiceTest { + + private final MatchRepo matchRepo = mock(MatchRepo.class); + private final MatchCycleRepo matchCycleRepo = mock(MatchCycleRepo.class); + private final MatchService matchService = new MatchService(matchRepo, matchCycleRepo); + + private MatchCycle stubCycle() { + return MatchCycle.builder() + .id(1) + .period("2026-07") + .runAt(Instant.parse("2026-07-15T10:00:00Z")) + .build(); + } + + @Test + void createMatch_successWithAllFields() { + final CreateMatchRequest request = + new CreateMatchRequest(UUID.randomUUID(), UUID.randomUUID(), 1, 0.85, "CONFIRMED"); + when(matchCycleRepo.getMatchCycleById(1)).thenReturn(Optional.of(stubCycle())); + when(matchRepo.createMatch(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + final AdminMatchResponse response = matchService.createMatch(request); + + assertNotNull(response.getMatchId()); + assertEquals(request.memberAId(), response.getMemberAId()); + assertEquals(request.memberBId(), response.getMemberBId()); + assertEquals(1, response.getMatchCycleId()); + assertEquals("2026-07", response.getMonth()); + assertEquals("CONFIRMED", response.getStatus()); + assertEquals(0.85, response.getMatchScore()); + } + + @Test + void createMatch_defaultsStatusToPendingWhenStatusMissing() { + final CreateMatchRequest request = new CreateMatchRequest(UUID.randomUUID(), UUID.randomUUID(), 1, null, null); + when(matchCycleRepo.getMatchCycleById(1)).thenReturn(Optional.of(stubCycle())); + when(matchRepo.createMatch(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + final AdminMatchResponse response = matchService.createMatch(request); + + assertEquals("PENDING", response.getStatus()); + } + + @Test + void createMatch_throwsMatchCycleNotFoundWhenCycleMissing() { + final CreateMatchRequest request = new CreateMatchRequest(UUID.randomUUID(), UUID.randomUUID(), 99, null, null); + when(matchCycleRepo.getMatchCycleById(99)).thenReturn(Optional.empty()); + + assertThrows(MatchCycleNotFoundException.class, () -> matchService.createMatch(request)); + verify(matchRepo, never()).createMatch(any()); + } +}