diff --git a/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleController.java b/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleController.java new file mode 100644 index 00000000..7240870c --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleController.java @@ -0,0 +1,53 @@ +package org.patinanetwork.patchats.api.match; + +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.dto.CreateMatchCycleRequest; +import org.patinanetwork.patchats.api.match.dto.MatchCycleResponse; +import org.patinanetwork.patchats.api.match.dto.UpdateMatchCycleRequest; +import org.patinanetwork.patchats.common.dto.ApiResponder; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +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/match_cycles") +@Tag(name = "Match Cycles") +@RequiredArgsConstructor +public class MatchCycleController { + + private final MatchCycleService matchCycleService; + + @PostMapping + public ResponseEntity> createMatchCycle( + @Valid @RequestBody final CreateMatchCycleRequest request) { + final MatchCycleResponse response = matchCycleService.createMatchCycle(request); + return ResponseEntity.ok(ApiResponder.success("Match Cycle created successfully", response)); + } + + @PatchMapping("/{id}") + public ResponseEntity> updateMatchCycle( + @Valid @RequestBody final UpdateMatchCycleRequest request, @PathVariable final Integer id) { + final MatchCycleResponse response = matchCycleService.updateMatchCycle(request, id); + return ResponseEntity.ok(ApiResponder.success("Match Cycle updated successfully", response)); + } + + @GetMapping("/{id}") + public ResponseEntity> getMatchCycleById(@PathVariable final Integer id) { + final MatchCycleResponse response = matchCycleService.getMatchCycleById(id); + return ResponseEntity.ok(ApiResponder.success("Match Cycle retrieved successfully", response)); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteMatchCycle(@PathVariable final Integer id) { + final MatchCycleResponse response = matchCycleService.deleteMatchCycleById(id); + return ResponseEntity.ok(ApiResponder.success("Match Cycle deleted successfully", response)); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleService.java b/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleService.java new file mode 100644 index 00000000..5e667edb --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/MatchCycleService.java @@ -0,0 +1,113 @@ +package org.patinanetwork.patchats.api.match; + +import java.time.Instant; +import java.util.Optional; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; +import org.patinanetwork.patchats.api.match.db.repos.MatchCycleRepo; +import org.patinanetwork.patchats.api.match.dto.CreateMatchCycleRequest; +import org.patinanetwork.patchats.api.match.dto.MatchCycleResponse; +import org.patinanetwork.patchats.api.match.dto.UpdateMatchCycleRequest; +import org.patinanetwork.patchats.common.web.exception.MatchCycleDuplicateException; +import org.patinanetwork.patchats.common.web.exception.MatchCycleNotFoundException; +import org.patinanetwork.patchats.common.web.exception.ValidationException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MatchCycleService { + + private final MatchCycleRepo matchCycleRepo; + + public MatchCycleResponse createMatchCycle(CreateMatchCycleRequest request) { + if (matchCycleRepo.getMatchCycleByPeriod(request.period()).isPresent()) { + throw new MatchCycleDuplicateException(request.period()); + } + + MatchCycle matchCycle = MatchCycle.builder() + .period(request.period()) + .runAt(request.runAt()) + .isDraft(request.isDraft()) + .build(); + + try { + MatchCycle createdMatchCycle = matchCycleRepo.createMatchCycle(matchCycle); + return MatchCycleResponse.from(createdMatchCycle); + } catch (DuplicateKeyException e) { + throw new MatchCycleDuplicateException(request.period()); + } + } + + public MatchCycleResponse updateMatchCycle(UpdateMatchCycleRequest request, Integer id) { + MatchCycle matchCycle = + matchCycleRepo.getMatchCycleById(id).orElseThrow(() -> new MatchCycleNotFoundException(id)); + + boolean hasNoUpdates = + Stream.of(request.period(), request.runAt(), request.isDraft()).noneMatch(Optional::isPresent); + if (hasNoUpdates) { + return MatchCycleResponse.from(matchCycle); + } + + if (request.period().isPresent()) { + String period = request.period().get(); + if (period.isBlank()) { + throw new ValidationException("period cannot be empty"); + } + matchCycle.setPeriod(period); + } + request.runAt().ifPresent(matchCycle::setRunAt); + request.isDraft().ifPresent(matchCycle::setIsDraft); + + try { + MatchCycle updated = + matchCycleRepo.updateMatchCycle(matchCycle).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(updated); + } catch (DuplicateKeyException e) { + throw new MatchCycleDuplicateException(request.period().orElse(matchCycle.getPeriod())); + } + } + + public MatchCycleResponse setMatchCyclePeriod(Integer id, String period) { + if (period == null || period.isBlank()) { + throw new ValidationException("period cannot be empty"); + } + MatchCycle matchCycle = + matchCycleRepo.setMatchCyclePeriod(id, period).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(matchCycle); + } + + public MatchCycleResponse setMatchCycleRunAt(Integer id, Instant runAt) { + MatchCycle matchCycle = + matchCycleRepo.setMatchCycleRunAt(id, runAt).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(matchCycle); + } + + public MatchCycleResponse setMatchCycleIsDraft(Integer id, boolean isDraft) { + MatchCycle matchCycle = + matchCycleRepo.setMatchCycleIsDraft(id, isDraft).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(matchCycle); + } + + public MatchCycleResponse getMatchCycleById(Integer id) { + MatchCycle matchCycle = + matchCycleRepo.getMatchCycleById(id).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(matchCycle); + } + + public MatchCycleResponse deleteMatchCycleById(Integer id) { + MatchCycle matchCycle = + matchCycleRepo.deleteMatchCycleById(id).orElseThrow(() -> new MatchCycleNotFoundException(id)); + return MatchCycleResponse.from(matchCycle); + } + + public MatchCycleResponse getMatchCycleByPeriod(String period) { + MatchCycle matchCycle = + matchCycleRepo.getMatchCycleByPeriod(period).orElseThrow(() -> new MatchCycleNotFoundException(period)); + return MatchCycleResponse.from(matchCycle); + } + + // public MatchCycleResponse filterMatchCycles(MatchCycleFilterCriteria + // criteria) {} +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/models/MatchCycle.java b/src/main/java/org/patinanetwork/patchats/api/match/db/models/MatchCycle.java index 54c75d7e..9756ae17 100644 --- a/src/main/java/org/patinanetwork/patchats/api/match/db/models/MatchCycle.java +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/models/MatchCycle.java @@ -8,11 +8,10 @@ import lombok.ToString; @Getter -@Builder @ToString +@Builder @EqualsAndHashCode(of = "id") public class MatchCycle { - private Integer id; @Setter @@ -20,4 +19,7 @@ public class MatchCycle { @Setter private Instant runAt; + + @Setter + private Boolean isDraft; } diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleFilterCriteria.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleFilterCriteria.java index 6654f1ac..dcc24387 100644 --- a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleFilterCriteria.java +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleFilterCriteria.java @@ -4,9 +4,9 @@ import java.util.Optional; public record MatchCycleFilterCriteria( - Optional period, Optional startTime, Optional endTime) { + Optional period, Optional startTime, Optional endTime, Optional isDraft) { public static MatchCycleFilterCriteria empty() { - return new MatchCycleFilterCriteria(Optional.empty(), Optional.empty(), Optional.empty()); + return new MatchCycleFilterCriteria(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); } } diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleRepo.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleRepo.java index c870e14d..aa9712b0 100644 --- a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleRepo.java +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleRepo.java @@ -1,5 +1,6 @@ package org.patinanetwork.patchats.api.match.db.repos; +import java.time.Instant; import java.util.List; import java.util.Optional; import org.patinanetwork.patchats.api.match.db.models.MatchCycle; @@ -10,13 +11,12 @@ public interface MatchCycleRepo { * @note - The provided object's methods will be overridden with any returned data from the database. * @param matchCycle - required fields: *
    - *
  • runAt + *
  • period *
* Optional fields: *
    - *
  • period - *
  • totalMembers - *
  • totalMatched + *
  • runAt + *
  • isDraft *
* The id field will be auto-generated by the database. */ @@ -28,14 +28,21 @@ public interface MatchCycleRepo { *
    *
  • period *
  • runAt - *
  • totalMembers - *
  • totalMatched + *
  • isDraft *
*/ Optional updateMatchCycle(MatchCycle matchCycle); Optional getMatchCycleById(Integer id); + Optional getMatchCycleByPeriod(String period); + + Optional setMatchCyclePeriod(Integer id, String period); + + Optional setMatchCycleRunAt(Integer id, Instant runAt); + + Optional setMatchCycleIsDraft(Integer id, boolean isDraft); + Optional deleteMatchCycleById(Integer id); List filterMatchCycles(MatchCycleFilterCriteria criteria); diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepo.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepo.java new file mode 100644 index 00000000..0c1047ce --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepo.java @@ -0,0 +1,193 @@ +package org.patinanetwork.patchats.api.match.db.repos; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MatchCycleSqlRepo implements MatchCycleRepo { + private final JdbcClient jdbc; + + private MatchCycle parseResultSetToMatchCycle(final ResultSet rs) throws SQLException { + return MatchCycle.builder() + .id(rs.getInt("id")) + .period(rs.getString("period")) + .runAt(rs.getObject("run_at", OffsetDateTime.class).toInstant()) + .isDraft(rs.getBoolean("is_draft")) + .build(); + } + + @Override + public MatchCycle createMatchCycle(MatchCycle matchCycle) { + String sql = """ + INSERT INTO "match_cycles" ( + "period", + "run_at", + "is_draft" + ) + VALUES( + :period, + :run_at, + :is_draft + ) + RETURNING * + """; + + return jdbc.sql(sql) + .param("period", matchCycle.getPeriod()) + .param( + "run_at", + matchCycle.getRunAt() != null ? matchCycle.getRunAt().atOffset(ZoneOffset.UTC) : null) + .param("is_draft", matchCycle.getIsDraft()) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .single(); + } + + @Override + public Optional updateMatchCycle(MatchCycle matchCycle) { + String sql = """ + UPDATE "match_cycles" SET + "period" = :period, + "run_at" = :run_at, + "is_draft" = :is_draft + WHERE "id" = :id + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", matchCycle.getId()) + .param("period", matchCycle.getPeriod()) + .param("run_at", matchCycle.getRunAt().atOffset(ZoneOffset.UTC)) + .param("is_draft", matchCycle.getIsDraft()) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional getMatchCycleById(Integer id) { + String sql = """ + SELECT * + FROM match_cycles + WHERE id = :id + """; + return jdbc.sql(sql) + .param("id", id) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional getMatchCycleByPeriod(String period) { + String sql = """ + SELECT * + FROM match_cycles + WHERE period = :period + """; + return jdbc.sql(sql) + .param("period", period) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional setMatchCyclePeriod(Integer id, String period) { + String sql = """ + UPDATE "match_cycles" SET + "period" = :period + WHERE "id" = :id + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", id) + .param("period", period) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional setMatchCycleRunAt(Integer id, Instant runAt) { + String sql = """ + UPDATE "match_cycles" SET + "run_at" = :run_at + WHERE "id" = :id + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", id) + .param("run_at", runAt != null ? runAt.atOffset(ZoneOffset.UTC) : null) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional setMatchCycleIsDraft(Integer id, boolean isDraft) { + String sql = """ + UPDATE "match_cycles" SET + "is_draft" = :is_draft + WHERE "id" = :id + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", id) + .param("is_draft", isDraft) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public Optional deleteMatchCycleById(Integer id) { + String sql = """ + DELETE FROM match_cycles + WHERE id = :id + RETURNING * + """; + return jdbc.sql(sql) + .param("id", id) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .optional(); + } + + @Override + public List filterMatchCycles(MatchCycleFilterCriteria criteria) { + StringBuilder sql = new StringBuilder("SELECT * FROM match_cycles WHERE 1=1"); + MapSqlParameterSource params = new MapSqlParameterSource(); + + criteria.period().ifPresent(period -> { + sql.append(" AND period = :period"); + params.addValue("period", period); + }); + + criteria.startTime().ifPresent(start -> { + sql.append(" AND run_at >= :start_time"); + params.addValue("start_time", start.atOffset(ZoneOffset.UTC)); + }); + + criteria.endTime().ifPresent(end -> { + sql.append(" AND run_at <= :end_time"); + params.addValue("end_time", end.atOffset(ZoneOffset.UTC)); + }); + + criteria.isDraft().ifPresent(isDraft -> { + sql.append(" AND is_draft = :is_draft"); + params.addValue("is_draft", isDraft); + }); + + return jdbc.sql(sql.toString()) + .paramSource(params) + .query((rs, rowNum) -> parseResultSetToMatchCycle(rs)) + .list(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchRepo.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchRepo.java index 8de182e7..0fbac632 100644 --- a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchRepo.java +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchRepo.java @@ -27,8 +27,6 @@ public interface MatchRepo { *
  • matchCycleId *
  • matchScore *
  • status - *
  • feedbackA - *
  • feedbackB * */ Optional updateMatch(Match match); @@ -37,9 +35,9 @@ public interface MatchRepo { Optional setMatchStatus(UUID id, String status); - Optional deleteMatchById(UUID id); + Optional setMatchScore(UUID id, Double score); - Optional recordFeedback(UUID id, UUID memberId, String feedback); + Optional deleteMatchById(UUID id); List filterMatches(MatchFilterCriteria criteria); } diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepo.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepo.java new file mode 100644 index 00000000..e34549fa --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepo.java @@ -0,0 +1,185 @@ +package org.patinanetwork.patchats.api.match.db.repos; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.patinanetwork.patchats.api.match.db.models.Match; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MatchSqlRepo implements MatchRepo { + private final JdbcClient jdbc; + + private Match parseResultSetToMatch(final ResultSet rs) throws SQLException { + final Float matchScore = rs.getObject("match_score", Float.class); + return Match.builder() + .id(UUID.fromString(rs.getString("id"))) + .memberAId(UUID.fromString(rs.getString("member_a_id"))) + .memberBId(UUID.fromString(rs.getString("member_b_id"))) + .matchCycleId(rs.getInt("cycle_id")) + .matchScore(matchScore == null ? null : matchScore.doubleValue()) + .status(rs.getString("status")) + .createdAt(rs.getObject("created_at", OffsetDateTime.class).toInstant()) + .build(); + } + + @Override + public Match createMatch(Match match) { + String sql = """ + INSERT INTO "matches" ( + "id", + "member_a_id", + "member_b_id", + "cycle_id", + "match_score", + "status" + ) + VALUES( + :id, + :member_a_id, + :member_b_id, + :cycle_id, + :match_score, + :status + ) + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", match.getId()) + .param("member_a_id", match.getMemberAId()) + .param("member_b_id", match.getMemberBId()) + .param("cycle_id", match.getMatchCycleId()) + .param("match_score", match.getMatchScore()) + .param("status", match.getStatus()) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .single(); + } + + @Override + public Optional updateMatch(Match match) { + String sql = """ + UPDATE "matches" SET + "member_a_id" = :member_a_id, + "member_b_id" = :member_b_id, + "cycle_id" = :cycle_id, + "match_score" = :match_score, + "status" = :status + WHERE "id" = :id + RETURNING * + """; + + return jdbc.sql(sql) + .param("id", match.getId()) + .param("member_a_id", match.getMemberAId()) + .param("member_b_id", match.getMemberBId()) + .param("cycle_id", match.getMatchCycleId()) + .param("match_score", match.getMatchScore()) + .param("status", match.getStatus()) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .optional(); + } + + @Override + public Optional getMatchById(UUID id) { + String sql = "SELECT * FROM matches WHERE id = :id"; + return jdbc.sql(sql) + .param("id", id) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .optional(); + } + + @Override + public Optional setMatchStatus(UUID id, String status) { + String sql = """ + UPDATE "matches" SET "status" = :status + WHERE "id" = :id + RETURNING * + """; + return jdbc.sql(sql) + .param("id", id) + .param("status", status) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .optional(); + } + + @Override + public Optional setMatchScore(UUID id, Double score) { + String sql = """ + UPDATE "matches" SET "match_score" = :score + WHERE "id" = :id + RETURNING * + """; + return jdbc.sql(sql) + .param("id", id) + .param("score", score) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .optional(); + } + + @Override + public Optional deleteMatchById(UUID id) { + String sql = "DELETE FROM matches WHERE id = :id RETURNING *"; + return jdbc.sql(sql) + .param("id", id) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .optional(); + } + + @Override + public List filterMatches(MatchFilterCriteria criteria) { + StringBuilder sql = new StringBuilder("SELECT * FROM matches WHERE 1=1"); + MapSqlParameterSource params = new MapSqlParameterSource(); + + criteria.status().ifPresent(status -> { + sql.append(" AND status = :status"); + params.addValue("status", status); + }); + + criteria.memberId().ifPresent(memberId -> { + sql.append(" AND (member_a_id = :member_id OR member_b_id = :member_id)"); + params.addValue("member_id", memberId); + }); + + criteria.matchCycleId().ifPresent(cycleId -> { + sql.append(" AND cycle_id = :cycle_id"); + params.addValue("cycle_id", cycleId); + }); + + criteria.startTime().ifPresent(start -> { + sql.append(" AND created_at >= :start_time"); + params.addValue("start_time", start); + }); + + criteria.endTime().ifPresent(end -> { + sql.append(" AND created_at <= :end_time"); + params.addValue("end_time", end); + }); + + criteria.period().ifPresent(period -> { + sql.append(" AND cycle_id IN (SELECT id FROM match_cycles WHERE period = :period)"); + params.addValue("period", period); + }); + + criteria.memberIndustry().ifPresent(memberIndustry -> { + sql.append(" AND ("); + sql.append("member_a_id IN (SELECT id FROM members WHERE industry_pref = :member_industry)"); + sql.append(" OR "); + sql.append("member_b_id IN (SELECT id FROM members WHERE industry_pref = :member_industry)"); + sql.append(")"); + params.addValue("member_industry", memberIndustry); + }); + + return jdbc.sql(sql.toString()) + .paramSource(params) + .query((rs, rowNum) -> parseResultSetToMatch(rs)) + .list(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/CreateMatchCycleRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/CreateMatchCycleRequest.java new file mode 100644 index 00000000..87860eff --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/CreateMatchCycleRequest.java @@ -0,0 +1,6 @@ +package org.patinanetwork.patchats.api.match.dto; + +import jakarta.validation.constraints.NotBlank; +import java.time.Instant; + +public record CreateMatchCycleRequest(@NotBlank String period, Instant runAt, Boolean isDraft) {} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/MatchCycleResponse.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/MatchCycleResponse.java new file mode 100644 index 00000000..0304e731 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/MatchCycleResponse.java @@ -0,0 +1,36 @@ +package org.patinanetwork.patchats.api.match.dto; + +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 Integer id; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String period; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Instant runAt; + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean isDraft; + + public static MatchCycleResponse from(final MatchCycle matchCycle) { + return MatchCycleResponse.builder() + .id(matchCycle.getId()) + .runAt(matchCycle.getRunAt()) + .period(matchCycle.getPeriod()) + .isDraft(matchCycle.getIsDraft()) + .build(); + } +} diff --git a/src/main/java/org/patinanetwork/patchats/api/match/dto/UpdateMatchCycleRequest.java b/src/main/java/org/patinanetwork/patchats/api/match/dto/UpdateMatchCycleRequest.java new file mode 100644 index 00000000..df745b7d --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/api/match/dto/UpdateMatchCycleRequest.java @@ -0,0 +1,6 @@ +package org.patinanetwork.patchats.api.match.dto; + +import java.time.Instant; +import java.util.Optional; + +public record UpdateMatchCycleRequest(Optional period, Optional runAt, Optional isDraft) {} 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..c3b13c77 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,8 @@ 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.MatchCycleDuplicateException; +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; @@ -55,6 +57,16 @@ public ResponseEntity> handleMemberDuplicate(final MemberDupl return ResponseEntity.status(HttpStatus.CONFLICT).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())); + } + + @ExceptionHandler(MatchCycleDuplicateException.class) + public ResponseEntity> handleMatchCycleDuplicate(final MatchCycleDuplicateException 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())); diff --git a/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleDuplicateException.java b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleDuplicateException.java new file mode 100644 index 00000000..a2a0f4a2 --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleDuplicateException.java @@ -0,0 +1,7 @@ +package org.patinanetwork.patchats.common.web.exception; + +public class MatchCycleDuplicateException extends RuntimeException { + public MatchCycleDuplicateException(String period) { + super("A Match Cycle already exists for period " + period); + } +} 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..807c90ab --- /dev/null +++ b/src/main/java/org/patinanetwork/patchats/common/web/exception/MatchCycleNotFoundException.java @@ -0,0 +1,11 @@ +package org.patinanetwork.patchats.common.web.exception; + +public class MatchCycleNotFoundException extends RuntimeException { + public MatchCycleNotFoundException(Integer id) { + super("Match Cycle with ID " + id + " not found"); + } + + public MatchCycleNotFoundException(String period) { + super("Match Cycle with period " + period + " not found"); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepoTest.java b/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepoTest.java new file mode 100644 index 00000000..26365c26 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchCycleSqlRepoTest.java @@ -0,0 +1,235 @@ +package org.patinanetwork.patchats.api.match.db.repos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.patinanetwork.patchats.api.match.db.models.MatchCycle; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.simple.JdbcClient; + +@SuppressWarnings("unchecked") +class MatchCycleSqlRepoTest { + + @Test + void createMatchCycleReturnsRowFromDatabase() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = MatchCycle.builder() + .id(1) + .period("2025-Q1") + .runAt(Instant.parse("2025-01-15T12:00:00Z")) + .isDraft(true) + .build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.single()).thenReturn(matchCycle); + + final MatchCycle result = new MatchCycleSqlRepo(jdbc).createMatchCycle(matchCycle); + + assertEquals(matchCycle, result); + verify(jdbc).sql(ArgumentMatchers.anyString()); + verify(statement).param("period", matchCycle.getPeriod()); + verify(statement).param("run_at", matchCycle.getRunAt().atOffset(ZoneOffset.UTC)); + verify(statement).param("is_draft", matchCycle.getIsDraft()); + verify(query).single(); + } + + @Test + void updateMatchCycleBindsAllFields() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = MatchCycle.builder() + .id(1) + .period("2025-Q2") + .runAt(Instant.parse("2025-04-15T12:00:00Z")) + .isDraft(false) + .build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).updateMatchCycle(matchCycle); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", matchCycle.getId()); + verify(statement).param("period", matchCycle.getPeriod()); + verify(statement).param("run_at", matchCycle.getRunAt().atOffset(ZoneOffset.UTC)); + verify(statement).param("is_draft", matchCycle.getIsDraft()); + verify(query).optional(); + } + + @Test + void getMatchCycleByIdBindsId() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = MatchCycle.builder().id(1).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).getMatchCycleById(1); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", 1); + verify(query).optional(); + } + + @Test + void setMatchCyclePeriodBindsPeriod() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = + MatchCycle.builder().id(1).period("2025-Q3").build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).setMatchCyclePeriod(1, "2025-Q3"); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", 1); + verify(statement).param("period", "2025-Q3"); + verify(query).optional(); + } + + @Test + void setMatchCycleRunAtBindsRunAt() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final Instant runAt = Instant.parse("2025-07-15T12:00:00Z"); + final MatchCycle matchCycle = MatchCycle.builder().id(1).runAt(runAt).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).setMatchCycleRunAt(1, runAt); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", 1); + verify(statement).param("run_at", runAt.atOffset(ZoneOffset.UTC)); + verify(query).optional(); + } + + @Test + void setMatchCycleIsDraftBindsDraft() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = MatchCycle.builder().id(1).isDraft(false).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).setMatchCycleIsDraft(1, false); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", 1); + verify(statement).param("is_draft", false); + verify(query).optional(); + } + + @Test + void deleteMatchCycleByIdBindsId() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycle matchCycle = MatchCycle.builder().id(1).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(matchCycle)); + + final Optional result = new MatchCycleSqlRepo(jdbc).deleteMatchCycleById(1); + + assertTrue(result.isPresent()); + assertEquals(matchCycle, result.get()); + verify(statement).param("id", 1); + verify(query).optional(); + } + + @Test + void filterMatchCyclesAppliesEveryProvidedCriterion() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final Instant start = Instant.parse("2025-01-01T00:00:00Z"); + final Instant end = Instant.parse("2025-06-30T23:59:59Z"); + final MatchCycleFilterCriteria criteria = new MatchCycleFilterCriteria( + Optional.of("2025-Q1"), Optional.of(start), Optional.of(end), Optional.of(true)); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.paramSource(ArgumentMatchers.any(MapSqlParameterSource.class))) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.list()).thenReturn(java.util.List.of()); + + final java.util.List result = new MatchCycleSqlRepo(jdbc).filterMatchCycles(criteria); + + assertEquals(java.util.List.of(), result); + verify(jdbc).sql(ArgumentMatchers.anyString()); + verify(query).list(); + } + + @Test + void filterMatchCyclesOnlyIncludesProvidedCriteriaInWhereClause() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchCycleFilterCriteria criteria = new MatchCycleFilterCriteria( + Optional.of("2025-Q1"), Optional.empty(), Optional.empty(), Optional.of(true)); + final ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.paramSource(ArgumentMatchers.any(MapSqlParameterSource.class))) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.list()).thenReturn(java.util.List.of()); + + new MatchCycleSqlRepo(jdbc).filterMatchCycles(criteria); + + verify(jdbc).sql(sqlCaptor.capture()); + assertEquals( + "SELECT * FROM match_cycles WHERE 1=1 AND period = :period AND is_draft = :is_draft", + sqlCaptor.getValue()); + } +} diff --git a/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepoTest.java b/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepoTest.java new file mode 100644 index 00000000..cb837e25 --- /dev/null +++ b/src/test/java/org/patinanetwork/patchats/api/match/db/repos/MatchSqlRepoTest.java @@ -0,0 +1,236 @@ +package org.patinanetwork.patchats.api.match.db.repos; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +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.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.patinanetwork.patchats.api.match.db.models.Match; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.simple.JdbcClient; + +@SuppressWarnings("unchecked") +class MatchSqlRepoTest { + + @Test + void createMatchReturnsRowFromDatabase() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final Match match = Match.builder() + .id(UUID.randomUUID()) + .memberAId(UUID.randomUUID()) + .memberBId(UUID.randomUUID()) + .matchCycleId(1) + .matchScore(8.5) + .status("pending") + .build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.single()).thenReturn(match); + + final Match result = new MatchSqlRepo(jdbc).createMatch(match); + + assertEquals(match, result); + verify(jdbc).sql(ArgumentMatchers.anyString()); + verify(statement).param("id", match.getId()); + verify(statement).param("member_a_id", match.getMemberAId()); + verify(statement).param("member_b_id", match.getMemberBId()); + verify(statement).param("cycle_id", match.getMatchCycleId()); + verify(statement).param("match_score", match.getMatchScore()); + verify(statement).param("status", match.getStatus()); + verify(query).single(); + } + + @Test + void updateMatchBindsAllFields() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final Match match = Match.builder() + .id(UUID.randomUUID()) + .memberAId(UUID.randomUUID()) + .memberBId(UUID.randomUUID()) + .matchCycleId(2) + .matchScore(9.0) + .status("confirmed") + .build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(match)); + + final Optional result = new MatchSqlRepo(jdbc).updateMatch(match); + + assertTrue(result.isPresent()); + assertEquals(match, result.get()); + verify(statement).param("id", match.getId()); + verify(statement).param("member_a_id", match.getMemberAId()); + verify(statement).param("member_b_id", match.getMemberBId()); + verify(statement).param("cycle_id", match.getMatchCycleId()); + verify(statement).param("match_score", match.getMatchScore()); + verify(statement).param("status", match.getStatus()); + verify(query).optional(); + } + + @Test + void getMatchByIdBindsId() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final UUID id = UUID.randomUUID(); + final Match match = Match.builder().id(id).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(match)); + + final Optional result = new MatchSqlRepo(jdbc).getMatchById(id); + + assertTrue(result.isPresent()); + assertEquals(match, result.get()); + verify(statement).param("id", id); + verify(query).optional(); + } + + @Test + void setMatchStatusBindsStatus() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final UUID id = UUID.randomUUID(); + final Match match = Match.builder().id(id).status("completed").build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(match)); + + final Optional result = new MatchSqlRepo(jdbc).setMatchStatus(id, "completed"); + + assertTrue(result.isPresent()); + assertEquals(match, result.get()); + verify(statement).param("id", id); + verify(statement).param("status", "completed"); + verify(query).optional(); + } + + @Test + void setMatchScoreBindsScore() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final UUID id = UUID.randomUUID(); + final Match match = Match.builder().id(id).matchScore(7.25).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(match)); + + final Optional result = new MatchSqlRepo(jdbc).setMatchScore(id, 7.25); + + assertTrue(result.isPresent()); + assertEquals(match, result.get()); + verify(statement).param("id", id); + verify(statement).param("score", 7.25); + verify(query).optional(); + } + + @Test + void deleteMatchByIdBindsId() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final UUID id = UUID.randomUUID(); + final Match match = Match.builder().id(id).build(); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any())) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.optional()).thenReturn(Optional.of(match)); + + final Optional result = new MatchSqlRepo(jdbc).deleteMatchById(id); + + assertTrue(result.isPresent()); + assertEquals(match, result.get()); + verify(statement).param("id", id); + verify(query).optional(); + } + + @Test + void filterMatchesAppliesEveryProvidedCriterion() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final Instant start = Instant.parse("2025-01-01T00:00:00Z"); + final Instant end = Instant.parse("2025-06-30T23:59:59Z"); + final MatchFilterCriteria criteria = new MatchFilterCriteria( + Optional.of(start), + Optional.of(end), + Optional.of("2025-Q1"), + Optional.of(UUID.randomUUID()), + Optional.of(1), + Optional.of("Technology"), + Optional.of("confirmed")); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.paramSource(ArgumentMatchers.any(MapSqlParameterSource.class))) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.list()).thenReturn(java.util.List.of()); + + final java.util.List result = new MatchSqlRepo(jdbc).filterMatches(criteria); + + assertEquals(java.util.List.of(), result); + verify(jdbc).sql(ArgumentMatchers.anyString()); + verify(query).list(); + } + + @Test + void filterMatchesOnlyIncludesProvidedCriteriaInWhereClause() { + final JdbcClient jdbc = mock(JdbcClient.class); + final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class); + final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class); + final MatchFilterCriteria criteria = new MatchFilterCriteria( + Optional.empty(), + Optional.empty(), + Optional.of("2025-Q1"), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("pending")); + final ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + + when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement); + when(statement.paramSource(ArgumentMatchers.any(MapSqlParameterSource.class))) + .thenReturn(statement); + when(statement.query(ArgumentMatchers.>any())).thenReturn(query); + when(query.list()).thenReturn(java.util.List.of()); + + new MatchSqlRepo(jdbc).filterMatches(criteria); + + verify(jdbc).sql(sqlCaptor.capture()); + assertEquals( + "SELECT * FROM matches WHERE 1=1 AND status = :status AND cycle_id IN (SELECT id FROM match_cycles WHERE period = :period)", + sqlCaptor.getValue()); + } +}