Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ApiResponder<AdminMatchResponse>> createMatch(
@Valid @RequestBody final CreateMatchRequest request) {
final AdminMatchResponse response = matchService.createMatch(request);
return ResponseEntity.ok(ApiResponder.success("Match created successfully", response));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,11 @@ public ResponseEntity<ApiResponder<Void>> handleValidation(ValidationException e
return ResponseEntity.badRequest().body(ApiResponder.failure(ex.getMessage()));
}

@ExceptionHandler(MatchCycleNotFoundException.class)
public ResponseEntity<ApiResponder<Void>> 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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}