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 0000000..0fe4230 --- /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 0000000..6dc3447 --- /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 0000000..494191c --- /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 0000000..4f416cc --- /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 0c75e40..90dcf48 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -5,6 +5,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; @@ -58,6 +59,11 @@ public ResponseEntity> handleValidation(ValidationException e return ResponseEntity.badRequest().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 0000000..b290439 --- /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 0000000..ee5c2ff --- /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 0000000..f234415 --- /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()); + } +}