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/MatchController.java b/src/main/java/org/patinanetwork/patchats/api/match/MatchController.java new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/MatchController.java @@ -0,0 +1 @@ + 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/cycle/CreateMatchCycleRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/CreateMatchCycleRequest.java new file mode 100644 index 0000000..cfc0df9 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/CreateMatchCycleRequest.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +import jakarta.validation.constraints.NotNull; +import java.time.Instant; + +public record CreateMatchCycleRequest( + @NotNull Instant runAt, String period, Integer totalMembers, Integer totalMatched) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailQuery.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailQuery.java new file mode 100644 index 0000000..b9a1b4b --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailQuery.java @@ -0,0 +1,3 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +public record MatchCycleDetailQuery(String memberIndustry, String status) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailResponse.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailResponse.java new file mode 100644 index 0000000..34aacad --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleDetailResponse.java @@ -0,0 +1,22 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.patinanetwork.patchats.api.match.dto.match.AdminMatchResponse; + +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class MatchCycleDetailResponse { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final MatchCycleResponse cycle; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final List matches; +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleListQuery.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleListQuery.java new file mode 100644 index 0000000..a066296 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleListQuery.java @@ -0,0 +1,5 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +import java.time.Instant; + +public record MatchCycleListQuery(String period, Instant startTime, Instant endTime) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleResponse.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleResponse.java new file mode 100644 index 0000000..b95a00f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/MatchCycleResponse.java @@ -0,0 +1,41 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; + +@Getter +@Builder +@ToString +@EqualsAndHashCode +public class MatchCycleResponse { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Integer matchCycleId; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private final String period; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Instant runAt; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Integer totalMembers; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final Integer totalMatched; + + public static MatchCycleResponse from(final MatchCycle cycle) { + return MatchCycleResponse.builder() + .matchCycleId(cycle.getId()) + .period(cycle.getPeriod()) + .runAt(cycle.getRunAt()) + .totalMembers(cycle.getTotalMembers()) + .totalMatched(cycle.getTotalMatched()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/UpdateMatchCycleRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/UpdateMatchCycleRequest.java new file mode 100644 index 0000000..f84bc98 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/cycle/UpdateMatchCycleRequest.java @@ -0,0 +1,5 @@ +package org.patinanetwork.patchats.api.match.dto.cycle; + +import java.time.Instant; + +public record UpdateMatchCycleRequest(Instant runAt, String period, Integer totalMembers, Integer totalMatched) {} 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..227c36f --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/AdminMatchResponse.java @@ -0,0 +1,62 @@ +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, nullable = true) + private final String feedbackA; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private final String feedbackB; + + @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()) + .feedbackA(match.getFeedbackA()) + .feedbackB(match.getFeedbackB()) + .createdAt(match.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/BulkCreateMatchesRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/BulkCreateMatchesRequest.java new file mode 100644 index 0000000..9dbaa83 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/BulkCreateMatchesRequest.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +public record BulkCreateMatchesRequest(@Valid @NotEmpty List matches) {} 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/api/match/dto/match/MatchListQuery.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/MatchListQuery.java new file mode 100644 index 0000000..e9d5dbe --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/MatchListQuery.java @@ -0,0 +1,6 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import java.time.Instant; +import java.util.UUID; + +public record MatchListQuery(UUID memberId, String period, Instant startTime, Instant endTime, String status) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/MatchResponse.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/MatchResponse.java new file mode 100644 index 0000000..68b6902 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/MatchResponse.java @@ -0,0 +1,49 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import io.swagger.v3.oas.annotations.media.Schema; +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 MatchResponse { + + @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 String month; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private final String status; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private final String feedbackA; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private final String feedbackB; + + public static MatchResponse from(final Match match, final String month) { + return MatchResponse.builder() + .matchId(match.getId()) + .memberAId(match.getMemberAId()) + .memberBId(match.getMemberBId()) + .month(month) + .status(match.getStatus()) + .feedbackA(match.getFeedbackA()) + .feedbackB(match.getFeedbackB()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchFeedbackRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchFeedbackRequest.java new file mode 100644 index 0000000..292eaa9 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchFeedbackRequest.java @@ -0,0 +1,5 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateMatchFeedbackRequest(@NotBlank String feedback) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchStatusRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchStatusRequest.java new file mode 100644 index 0000000..ec923f4 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/match/UpdateMatchStatusRequest.java @@ -0,0 +1,5 @@ +package org.patinanetwork.patchats.api.match.dto.match; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateMatchStatusRequest(@NotBlank 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 7501ad8..5ccb189 100644 --- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java +++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java @@ -2,6 +2,8 @@ import java.util.stream.Collectors; import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; +import org.patinanetwork.patchats.common.web.exception.MatchNotFoundException; import org.patinanetwork.patchats.common.web.exception.MemberDuplicateException; import org.patinanetwork.patchats.common.web.exception.MemberNotFoundException; import org.springframework.http.HttpStatus; @@ -34,6 +36,16 @@ public ResponseEntity> handleMemberDuplicate(final MemberDupl return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponder.failure(ex.getMessage())); } + @ExceptionHandler(MatchNotFoundException.class) + public ResponseEntity> handleMatchNotFound(final MatchNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).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/main/java/org/patinanetwork/patchats/common/web/exception/MatchNotFoundException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchNotFoundException.java new file mode 100644 index 0000000..ff8afff --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchNotFoundException.java @@ -0,0 +1,9 @@ +package org.patinanetwork.patchats.common.web.exception; + +import java.util.UUID; + +public class MatchNotFoundException extends RuntimeException { + public MatchNotFoundException(UUID id) { + super("Match 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..1b90c9e --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/api/match/MatchServiceTest.java @@ -0,0 +1,76 @@ +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")) + .totalMembers(10) + .totalMatched(8) + .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()); + } +}