.mock.ts`; compose them here.
*/
-export const server = setupServer(...sampleHandlers);
+export const server = setupServer(...membersHandlers, ...sampleHandlers);
diff --git a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
index 3b9bab1b..4191a96c 100644
--- a/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
+++ b/src/main/java/org/patinanetwork/patchats/api/auth/security/SecurityConfig.java
@@ -11,7 +11,7 @@
public class SecurityConfig {
/**
- * Default/production chain. Sending email is admin-only.
+ * Default/production chain. Sending email and listing members are admin-only.
*
* NOTE: authentication (OAuth2 login / roles) is not yet wired, so this rule currently fails closed — every
* caller is denied because no security context is populated. Once the auth domain authenticates admins, the rule
@@ -22,6 +22,8 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.requestMatchers(HttpMethod.POST, "/api/email/**")
+ .hasRole("ADMIN")
+ .requestMatchers(HttpMethod.GET, "/api/members")
.hasRole("ADMIN")
.anyRequest()
.permitAll())
diff --git a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchFilterCriteria.java b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchFilterCriteria.java
index 1e4cc128..a1f01423 100644
--- a/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchFilterCriteria.java
+++ b/src/main/java/org/patinanetwork/patchats/api/match/db/repos/MatchFilterCriteria.java
@@ -11,16 +11,4 @@ public record MatchFilterCriteria(
Optional memberId,
Optional matchCycleId,
Optional memberIndustry,
- Optional status) {
-
- public static MatchFilterCriteria empty() {
- return new MatchFilterCriteria(
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty(),
- Optional.empty());
- }
-}
+ Optional status) {}
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
index c46f4497..2fdc762f 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
@@ -1,9 +1,15 @@
package org.patinanetwork.patchats.api.member;
+import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import java.util.List;
+import java.util.Optional;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
+import org.patinanetwork.patchats.api.member.db.repos.MemberFilterCriteria;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
import org.patinanetwork.patchats.api.member.dto.MemberDto;
import org.patinanetwork.patchats.api.member.dto.UpdateMemberRequest;
@@ -15,6 +21,7 @@
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -31,6 +38,28 @@ public ResponseEntity> createMember(@Valid @RequestBody
return ResponseEntity.ok(ApiResponder.success("Member created successfully", response));
}
+ @GetMapping
+ @Operation(
+ summary = "List members",
+ description = "Returns a page of members matching the supplied filters, ordered by creation date. Pages "
+ + "are one-based; pageSize defaults to 25 and cannot exceed 100.")
+ public ResponseEntity>> getMembers(
+ @RequestParam final Optional firstName,
+ @RequestParam final Optional lastName,
+ @RequestParam final Optional email,
+ @RequestParam final Optional active,
+ @RequestParam final Optional matchPref,
+ @RequestParam final Optional industryPref,
+ @RequestParam final Optional rolePref,
+ @RequestParam final Optional topics,
+ @RequestParam(defaultValue = "1") @Min(1) final int page,
+ @RequestParam(defaultValue = "25") @Min(1) @Max(100) final int pageSize) {
+ final MemberFilterCriteria criteria = new MemberFilterCriteria(
+ firstName, lastName, email, active, matchPref, industryPref, rolePref, topics, page, pageSize);
+ final List response = memberService.getMembersByFilters(criteria);
+ return ResponseEntity.ok(ApiResponder.success("Members retrieved successfully", response));
+ }
+
@PatchMapping("/{id}")
public ResponseEntity> updateMember(
@Valid @RequestBody final UpdateMemberRequest request, @PathVariable final UUID id) {
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
index dbf4bd15..c1246dbe 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberService.java
@@ -7,6 +7,7 @@
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.patinanetwork.patchats.api.member.db.repos.MemberFilterCriteria;
import org.patinanetwork.patchats.api.member.db.repos.MemberRepo;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
import org.patinanetwork.patchats.api.member.dto.MemberDto;
@@ -46,8 +47,10 @@ public MemberDto createMember(CreateMemberRequest request) {
return MemberDto.from(createdMember);
}
- public List getMembers() {
- return memberRepo.getMembers().stream().map(MemberDto::from).toList();
+ public List getMembersByFilters(MemberFilterCriteria criteria) {
+ return memberRepo.getMembersByFilters(criteria).stream()
+ .map(MemberDto::from)
+ .toList();
}
public MemberDto updateMember(UpdateMemberRequest request, UUID id) {
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteria.java b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteria.java
new file mode 100644
index 00000000..1bd12146
--- /dev/null
+++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteria.java
@@ -0,0 +1,40 @@
+package org.patinanetwork.patchats.api.member.db.repos;
+
+import java.util.Optional;
+import lombok.Builder;
+
+@Builder
+public record MemberFilterCriteria(
+ Optional firstName,
+ Optional lastName,
+ Optional email,
+ Optional active,
+ Optional matchPref,
+ Optional industryPref,
+ Optional rolePref,
+ Optional topics,
+ int page,
+ int pageSize) {
+
+ public static final int DEFAULT_PAGE = 1;
+ public static final int DEFAULT_PAGE_SIZE = 25;
+ public static final int MAX_PAGE_SIZE = 100;
+
+ public static class MemberFilterCriteriaBuilder {
+ private int page = DEFAULT_PAGE;
+ private int pageSize = DEFAULT_PAGE_SIZE;
+ }
+
+ public MemberFilterCriteria {
+ if (page < 1) {
+ throw new IllegalArgumentException("page must be at least 1");
+ }
+ if (pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
+ throw new IllegalArgumentException("pageSize must be between 1 and " + MAX_PAGE_SIZE);
+ }
+ }
+
+ public long offset() {
+ return (long) (page - 1) * pageSize;
+ }
+}
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
index 8d280e6b..9a3b70d1 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberRepo.java
@@ -22,7 +22,7 @@ public interface MemberRepo {
*/
Member createMember(Member member);
- List getMembers();
+ List getMembersByFilters(MemberFilterCriteria criteria);
/**
* @note - The provided object's methods will be overridden with any returned data from the database.
diff --git a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
index cabf9a8d..0c7225cb 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepo.java
@@ -3,11 +3,15 @@
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.UUID;
+import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@@ -15,9 +19,82 @@
@RequiredArgsConstructor
public class MemberSqlRepo implements MemberRepo {
+ public static final String CREATE_MEMBER_SQL = """
+ INSERT INTO "members" (
+ "id",
+ "first_name",
+ "last_name",
+ "email",
+ "linked_in_url",
+ "introduction",
+ "referral_source",
+ "active",
+ "match_pref",
+ "industry_pref",
+ "role_pref",
+ "topics",
+ "extra_notes"
+ )
+ VALUES(
+ :id,
+ :first_name,
+ :last_name,
+ :email,
+ :linked_in_url,
+ :introduction,
+ :referral_source,
+ :active ,
+ :match_pref,
+ :industry_pref,
+ :role_pref,
+ :topics,
+ :extra_notes
+ )
+ RETURNING
+ *
+ """;
+
+ public static final String GET_MEMBERS_BY_FILTERS_SQL = """
+ SELECT
+ *
+ FROM
+ members
+ %sORDER BY
+ created_at DESC,
+ id
+ LIMIT
+ :page_size
+ OFFSET
+ :offset
+ """;
+
+ public static final String UPDATE_MEMBER_SQL = """
+ UPDATE "members" SET
+ "first_name" = :first_name,
+ "last_name" = :last_name,
+ "email" = :email,
+ "linked_in_url" = :linked_in_url,
+ "introduction" = :introduction,
+ "referral_source" = :referral_source,
+ "active" = :active,
+ "match_pref" = :match_pref,
+ "industry_pref" = :industry_pref,
+ "role_pref" = :role_pref,
+ "topics" = :topics,
+ "extra_notes" = :extra_notes,
+ "updated_at" = NOW()
+ WHERE "id" = :id
+ RETURNING *
+ """;
+
+ public static final String GET_MEMBER_BY_ID_SQL = "SELECT * FROM members WHERE id = :id";
+ public static final String GET_MEMBER_BY_EMAIL_SQL = "SELECT * FROM members WHERE email = :email";
+
+ private static final RowMapper MEMBER_ROW_MAPPER = (rs, rowNum) -> parseResultSetToMember(rs);
+
private final JdbcClient jdbc;
- private Member parseResultSetToMember(final ResultSet rs) throws SQLException {
+ private static Member parseResultSetToMember(final ResultSet rs) throws SQLException {
return Member.builder()
.id(UUID.fromString(rs.getString("id")))
.firstName(rs.getString("first_name"))
@@ -55,91 +132,68 @@ private JdbcClient.StatementSpec bindMemberParams(JdbcClient.StatementSpec spec,
@Override
public Member createMember(Member member) {
- String sql = """
- INSERT INTO "members" (
- "id",
- "first_name",
- "last_name",
- "email",
- "linked_in_url",
- "introduction",
- "referral_source",
- "active",
- "match_pref",
- "industry_pref",
- "role_pref",
- "topics",
- "extra_notes"
- )
- VALUES(
- :id,
- :first_name,
- :last_name,
- :email,
- :linked_in_url,
- :introduction,
- :referral_source,
- :active ,
- :match_pref,
- :industry_pref,
- :role_pref,
- :topics,
- :extra_notes
- )
- RETURNING
- *
- """;
- return bindMemberParams(jdbc.sql(sql), member)
- .query((rs, rowNum) -> parseResultSetToMember(rs))
+ return bindMemberParams(jdbc.sql(CREATE_MEMBER_SQL), member)
+ .query(MEMBER_ROW_MAPPER)
.single();
}
@Override
- public List getMembers() {
- String sql = "SELECT * FROM members ORDER BY created_at DESC, id";
- return jdbc.sql(sql).query((rs, rowNum) -> parseResultSetToMember(rs)).list();
+ public List getMembersByFilters(MemberFilterCriteria criteria) {
+ final Map filters = getMemberFilters(criteria);
+
+ JdbcClient.StatementSpec statement = jdbc.sql(buildGetMembersByFiltersSql(filters));
+ if (!filters.isEmpty()) {
+ statement = statement.params(filters);
+ }
+ statement = statement.param("page_size", criteria.pageSize()).param("offset", criteria.offset());
+
+ return statement.query(MEMBER_ROW_MAPPER).list();
+ }
+
+ private static Map getMemberFilters(MemberFilterCriteria criteria) {
+ final Map filters = new LinkedHashMap<>();
+ criteria.firstName().ifPresent(value -> filters.put("first_name", value));
+ criteria.lastName().ifPresent(value -> filters.put("last_name", value));
+ criteria.email().ifPresent(value -> filters.put("email", value));
+ criteria.active().ifPresent(value -> filters.put("active", value));
+ criteria.matchPref().ifPresent(value -> filters.put("match_pref", value));
+ criteria.industryPref().ifPresent(value -> filters.put("industry_pref", value));
+ criteria.rolePref().ifPresent(value -> filters.put("role_pref", value));
+ criteria.topics().ifPresent(value -> filters.put("topics", value));
+ return filters;
+ }
+
+ private static String buildGetMembersByFiltersSql(Map filters) {
+ final String whereClause = filters.isEmpty()
+ ? ""
+ : filters.keySet().stream()
+ .map(column -> "active".equals(column)
+ ? " active = :active"
+ : " LOWER(" + column + ") = LOWER(:" + column + ")")
+ .collect(Collectors.joining("\nAND\n", "WHERE\n", "\n"));
+ return GET_MEMBERS_BY_FILTERS_SQL.formatted(whereClause);
}
@Override
public Optional updateMember(Member member) {
- String sql = """
- UPDATE "members" SET
- "first_name" = :first_name,
- "last_name" = :last_name,
- "email" = :email,
- "linked_in_url" = :linked_in_url,
- "introduction" = :introduction,
- "referral_source" = :referral_source,
- "active" = :active,
- "match_pref" = :match_pref,
- "industry_pref" = :industry_pref,
- "role_pref" = :role_pref,
- "topics" = :topics,
- "extra_notes" = :extra_notes,
- "updated_at" = NOW()
- WHERE "id" = :id
- RETURNING *
- """;
- return bindMemberParams(jdbc.sql(sql), member)
- .query((rs, rowNum) -> parseResultSetToMember(rs))
+ return bindMemberParams(jdbc.sql(UPDATE_MEMBER_SQL), member)
+ .query(MEMBER_ROW_MAPPER)
.optional();
}
@Override
public Optional getMemberById(UUID id) {
- String sql = "SELECT * FROM members WHERE id = :id";
- return jdbc.sql(sql)
+ return jdbc.sql(GET_MEMBER_BY_ID_SQL)
.param("id", id)
- .query((rs, rowNum) -> parseResultSetToMember(rs))
+ .query(MEMBER_ROW_MAPPER)
.optional();
}
@Override
public Optional getMemberByEmail(String email) {
- String sql = "SELECT * FROM members WHERE email = :email";
- return jdbc.sql(sql)
+ return jdbc.sql(GET_MEMBER_BY_EMAIL_SQL)
.param("email", email)
- .query((rs, rowNum) -> parseResultSetToMember(rs))
+ .query(MEMBER_ROW_MAPPER)
.optional();
}
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 0c75e400..32ddab0a 100644
--- a/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
+++ b/src/main/java/org/patinanetwork/patchats/common/web/ApiExceptionHandler.java
@@ -14,6 +14,8 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.HandlerMethodValidationException;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/** Maps framework exceptions to the standard {@link ApiResponder} envelope. */
@RestControllerAdvice
@@ -28,6 +30,18 @@ public ResponseEntity> handleValidation(final MethodArgumentN
return ResponseEntity.badRequest().body(ApiResponder.failure(message));
}
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ public ResponseEntity> handleParameterTypeMismatch(
+ final MethodArgumentTypeMismatchException ex) {
+ final String message = "Invalid value for query parameter '" + ex.getName() + "'";
+ return ResponseEntity.badRequest().body(ApiResponder.failure(message));
+ }
+
+ @ExceptionHandler(HandlerMethodValidationException.class)
+ public ResponseEntity> handleParameterValidation(final HandlerMethodValidationException ex) {
+ return ResponseEntity.badRequest().body(ApiResponder.failure("Invalid query parameters"));
+ }
+
@ExceptionHandler(MemberNotFoundException.class)
public ResponseEntity> handleMemberNotFound(final MemberNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponder.failure(ex.getMessage()));
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
index 984f6f3a..f2fa06ef 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
@@ -12,10 +12,12 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.OffsetDateTime;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.patinanetwork.patchats.api.member.db.repos.MemberFilterCriteria;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
import org.patinanetwork.patchats.api.member.dto.MemberDto;
import org.patinanetwork.patchats.api.member.dto.UpdateMemberRequest;
@@ -69,11 +71,9 @@ void createMember_returnsOkAndMemberDto() throws Exception {
.topics(request.topics())
.extraNotes(request.extraNotes())
.build());
- mockMvc.perform(
- post("/api/members")
- .contentType(MediaType.APPLICATION_JSON)
- .content(
- "{\"firstName\":\"John\",\"lastName\":\"Doe\",\"email\":\"john.doe@example.com\",\"linkedInUrl\":\"https://www.linkedin.com/in/johndoe\",\"introduction\":\"Hello, I'm John!\",\"referralSource\":\"Friend\",\"matchPref\":\"Mentor - I am looking for guidance from someone with more experience\",\"industryPref\":\"Technology\",\"rolePref\":\"Software Engineer\",\"topics\":\"College, Career Development\",\"extraNotes\":\"I want to be meet someone in person in NYC\"}"))
+ mockMvc.perform(post("/api/members")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(memberRequestJson("John")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.payload.firstName").value(request.firstName()))
@@ -93,11 +93,9 @@ void createMember_returnsOkAndMemberDto() throws Exception {
@Test
void createMemberReturnsBadRequestWhenFirstNameIsBlank() throws Exception {
- mockMvc.perform(
- post("/api/members")
- .contentType(MediaType.APPLICATION_JSON)
- .content(
- "{\"firstName\":\"\",\"lastName\":\"Doe\",\"email\":\"john.doe@example.com\",\"linkedInUrl\":\"https://www.linkedin.com/in/johndoe\",\"introduction\":\"Hello, I'm John!\",\"referralSource\":\"Friend\",\"matchPref\":\"Mentor - I am looking for guidance from someone with more experience\",\"industryPref\":\"Technology\",\"rolePref\":\"Software Engineer\",\"topics\":\"College, Career Development\",\"extraNotes\":\"I want to be meet someone in person in NYC\"}"))
+ mockMvc.perform(post("/api/members")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(memberRequestJson("")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@@ -105,11 +103,9 @@ void createMemberReturnsBadRequestWhenFirstNameIsBlank() throws Exception {
@Test
void createMemberReturnsConflictOnDuplicateEmail() throws Exception {
when(memberService.createMember(any())).thenThrow(new MemberDuplicateException("john.doe@example.com"));
- mockMvc.perform(
- post("/api/members")
- .contentType(MediaType.APPLICATION_JSON)
- .content(
- "{\"firstName\":\"John\",\"lastName\":\"Doe\",\"email\":\"john.doe@example.com\",\"linkedInUrl\":\"https://www.linkedin.com/in/johndoe\",\"introduction\":\"Hello, I'm John!\",\"referralSource\":\"Friend\",\"matchPref\":\"Mentor - I am looking for guidance from someone with more experience\",\"industryPref\":\"Technology\",\"rolePref\":\"Software Engineer\",\"topics\":\"College, Career Development\",\"extraNotes\":\"I want to be meet someone in person in NYC\"}"))
+ mockMvc.perform(post("/api/members")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(memberRequestJson("John")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.success").value(false));
}
@@ -354,4 +350,117 @@ void updateMember_badRequestWhenInvalidUuid() throws Exception {
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
+
+ @Test
+ void getMembersReturnsAllMembers() throws Exception {
+ final MemberDto firstMember = MemberDto.builder()
+ .id(UUID.randomUUID())
+ .firstName("John")
+ .lastName("Doe")
+ .email("john.doe@example.com")
+ .active(true)
+ .build();
+ final MemberDto secondMember = MemberDto.builder()
+ .id(UUID.randomUUID())
+ .firstName("Jane")
+ .lastName("Doe")
+ .email("jane.doe@example.com")
+ .active(false)
+ .build();
+ when(memberService.getMembersByFilters(emptyCriteria())).thenReturn(List.of(firstMember, secondMember));
+
+ mockMvc.perform(get("/api/members"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.payload.length()").value(2))
+ .andExpect(jsonPath("$.payload[0].firstName").value("John"))
+ .andExpect(jsonPath("$.payload[0].lastName").value("Doe"))
+ .andExpect(jsonPath("$.payload[1].email").value("jane.doe@example.com"));
+ }
+
+ @Test
+ void getMembersCreatesCriteriaFromQueryParameters() throws Exception {
+ final MemberFilterCriteria criteria = new MemberFilterCriteria(
+ Optional.of("John"),
+ Optional.of("Doe"),
+ Optional.of("john.doe@example.com"),
+ Optional.of(true),
+ Optional.of("Peer"),
+ Optional.of("Technology"),
+ Optional.of("Software Engineer"),
+ Optional.of("Career Development"),
+ 2,
+ 10);
+ when(memberService.getMembersByFilters(criteria)).thenReturn(List.of());
+
+ mockMvc.perform(get("/api/members")
+ .queryParam("firstName", "John")
+ .queryParam("lastName", "Doe")
+ .queryParam("email", "john.doe@example.com")
+ .queryParam("active", "true")
+ .queryParam("matchPref", "Peer")
+ .queryParam("industryPref", "Technology")
+ .queryParam("rolePref", "Software Engineer")
+ .queryParam("topics", "Career Development")
+ .queryParam("page", "2")
+ .queryParam("pageSize", "10"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.success").value(true))
+ .andExpect(jsonPath("$.payload.length()").value(0));
+
+ verify(memberService).getMembersByFilters(criteria);
+ }
+
+ @Test
+ void getMembersReturnsBadRequestWhenActiveIsInvalid() throws Exception {
+ mockMvc.perform(get("/api/members").queryParam("active", "tru"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.message").value("Invalid value for query parameter 'active'"));
+ }
+
+ @Test
+ void getMembersReturnsBadRequestWhenPaginationIsOutOfRange() throws Exception {
+ mockMvc.perform(get("/api/members").queryParam("page", "0"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.message").value("Invalid query parameters"));
+
+ mockMvc.perform(get("/api/members").queryParam("pageSize", "101"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.success").value(false))
+ .andExpect(jsonPath("$.message").value("Invalid query parameters"));
+ }
+
+ private static String memberRequestJson(String firstName) {
+ return """
+ {
+ "firstName": "%s",
+ "lastName": "Doe",
+ "email": "john.doe@example.com",
+ "linkedInUrl": "https://www.linkedin.com/in/johndoe",
+ "introduction": "Hello, I'm John!",
+ "referralSource": "Friend",
+ "matchPref": "Mentor - I am looking for guidance from someone with more experience",
+ "industryPref": "Technology",
+ "rolePref": "Software Engineer",
+ "topics": "College, Career Development",
+ "extraNotes": "I want to be meet someone in person in NYC"
+ }
+ """.formatted(firstName);
+ }
+
+ private static MemberFilterCriteria emptyCriteria() {
+ return new MemberFilterCriteria(
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ MemberFilterCriteria.DEFAULT_PAGE,
+ MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+ }
}
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
index e53c68a6..f14a39c9 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
@@ -10,11 +10,13 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.patinanetwork.patchats.api.member.db.models.Member;
+import org.patinanetwork.patchats.api.member.db.repos.MemberFilterCriteria;
import org.patinanetwork.patchats.api.member.db.repos.MemberRepo;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
import org.patinanetwork.patchats.api.member.dto.MemberDto;
@@ -143,7 +145,7 @@ void getMemberById_throwsExceptionWhenMemberNotFound() {
}
@Test
- void updateMember_throwsExceptionWhenMemberNotFound() {
+ void updateMemberThrowsExceptionWhenMemberNotFound() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = MemberTestFixtures.UPDATE_REQUEST_ALL_FIELDS;
@@ -154,7 +156,7 @@ void updateMember_throwsExceptionWhenMemberNotFound() {
}
@Test
- void updateMember_successWithOnlyNameField() {
+ void updateMemberSuccessWithOnlyNameField() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = new UpdateMemberRequest(
Optional.of("UpdatedFirstName"),
@@ -199,7 +201,7 @@ void updateMember_successWithOnlyNameField() {
}
@Test
- void updateMember_successWithAllNullFields() {
+ void updateMemberSuccessWithAllNullFields() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = new UpdateMemberRequest(
Optional.empty(),
@@ -240,7 +242,7 @@ void updateMember_successWithAllNullFields() {
}
@Test
- void updateMember_successWithAllFields() {
+ void updateMemberSuccessWithAllFields() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = MemberTestFixtures.UPDATE_REQUEST_ALL_FIELDS;
@@ -347,4 +349,75 @@ void updateMember_successWhenUpdatingWithSameEmail() {
assertEquals("john@example.com", captured.getEmail());
verify(memberRepo, never()).getMemberByEmail(any());
}
+
+ @Test
+ void getMembers_returnsEveryMemberAsDto() {
+ final MemberFilterCriteria criteria = emptyCriteria();
+
+ final Member firstMember = Member.builder()
+ .id(UUID.randomUUID())
+ .firstName("John")
+ .lastName("Doe")
+ .email("john.doe@example.com")
+ .active(true)
+ .build();
+ final Member secondMember = Member.builder()
+ .id(UUID.randomUUID())
+ .firstName("Jane")
+ .lastName("Doe")
+ .email("jane.doe@example.com")
+ .active(false)
+ .build();
+ when(memberRepo.getMembersByFilters(criteria)).thenReturn(List.of(firstMember, secondMember));
+
+ final List response = memberService.getMembersByFilters(criteria);
+
+ assertEquals(2, response.size());
+ assertEquals(firstMember.getId(), response.get(0).getId());
+ assertEquals("John", response.get(0).getFirstName());
+ assertEquals("Doe", response.get(0).getLastName());
+ assertEquals("jane.doe@example.com", response.get(1).getEmail());
+ }
+
+ @Test
+ void getMembersReturnsEveryMemberAsDto() {
+ final MemberFilterCriteria criteria = emptyCriteria();
+ final Member firstMember = Member.builder()
+ .id(UUID.randomUUID())
+ .firstName("John")
+ .lastName("Doe")
+ .email("john.doe@example.com")
+ .active(true)
+ .build();
+ final Member secondMember = Member.builder()
+ .id(UUID.randomUUID())
+ .firstName("Jane")
+ .lastName("Doe")
+ .email("jane.doe@example.com")
+ .active(false)
+ .build();
+ when(memberRepo.getMembersByFilters(criteria)).thenReturn(List.of(firstMember, secondMember));
+
+ final List response = memberService.getMembersByFilters(criteria);
+
+ assertEquals(2, response.size());
+ assertEquals(firstMember.getId(), response.get(0).getId());
+ assertEquals("John", response.get(0).getFirstName());
+ assertEquals("Doe", response.get(0).getLastName());
+ assertEquals("jane.doe@example.com", response.get(1).getEmail());
+ }
+
+ private static MemberFilterCriteria emptyCriteria() {
+ return new MemberFilterCriteria(
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ MemberFilterCriteria.DEFAULT_PAGE,
+ MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+ }
}
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteriaTest.java b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteriaTest.java
new file mode 100644
index 00000000..e812138a
--- /dev/null
+++ b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberFilterCriteriaTest.java
@@ -0,0 +1,88 @@
+package org.patinanetwork.patchats.api.member.db.repos;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+
+class MemberFilterCriteriaTest {
+
+ @Test
+ void defaultCriteriaHasDefaultPagination() {
+ final MemberFilterCriteria criteria = MemberFilterCriteria.builder().build();
+
+ assertAll(
+ () -> assertEquals(MemberFilterCriteria.DEFAULT_PAGE, criteria.page()),
+ () -> assertEquals(MemberFilterCriteria.DEFAULT_PAGE_SIZE, criteria.pageSize()));
+ }
+
+ @Test
+ void criteriaWithoutFiltersHasExpectedValues() {
+ final MemberFilterCriteria criteria =
+ criteriaWithPagination(MemberFilterCriteria.DEFAULT_PAGE, MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+
+ assertAll(
+ () -> assertTrue(criteria.firstName().isEmpty()),
+ () -> assertTrue(criteria.lastName().isEmpty()),
+ () -> assertTrue(criteria.email().isEmpty()),
+ () -> assertTrue(criteria.active().isEmpty()),
+ () -> assertTrue(criteria.matchPref().isEmpty()),
+ () -> assertTrue(criteria.industryPref().isEmpty()),
+ () -> assertTrue(criteria.rolePref().isEmpty()),
+ () -> assertTrue(criteria.topics().isEmpty()),
+ () -> assertEquals(MemberFilterCriteria.DEFAULT_PAGE, criteria.page()),
+ () -> assertEquals(MemberFilterCriteria.DEFAULT_PAGE_SIZE, criteria.pageSize()),
+ () -> assertEquals(0, criteria.offset()));
+ }
+
+ @Test
+ void calculatesOffsetFromPageAndPageSize() {
+ final MemberFilterCriteria criteria = new MemberFilterCriteria(
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ 3,
+ 20);
+
+ assertEquals(40, criteria.offset());
+ }
+
+ @Test
+ void rejectsPageSizeGreaterThanMaxPageSize() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> criteriaWithPagination(
+ MemberFilterCriteria.DEFAULT_PAGE, MemberFilterCriteria.MAX_PAGE_SIZE + 1));
+ }
+
+ @Test
+ void rejectsInvalidPagination() {
+ assertAll(
+ () -> assertThrows(
+ IllegalArgumentException.class,
+ () -> criteriaWithPagination(0, MemberFilterCriteria.DEFAULT_PAGE_SIZE)),
+ () -> assertThrows(IllegalArgumentException.class, () -> criteriaWithPagination(1, 0)));
+ }
+
+ private static MemberFilterCriteria criteriaWithPagination(int page, int pageSize) {
+ return new MemberFilterCriteria(
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ page,
+ pageSize);
+ }
+}
diff --git a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java
index f40a80ea..3bd12d95 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/db/repos/MemberSqlRepoTest.java
@@ -6,8 +6,11 @@
import static org.mockito.Mockito.when;
import java.util.List;
+import java.util.Map;
+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.member.db.models.Member;
import org.springframework.jdbc.core.RowMapper;
@@ -15,13 +18,12 @@
class MemberSqlRepoTest {
- private static final String GET_MEMBERS_SQL = "SELECT * FROM members ORDER BY created_at DESC, id";
-
@Test
- void getMembersReturnsRowsFromDatabase() {
+ void getMembersAllReturnsRowsFromDatabase() {
final JdbcClient jdbc = mock(JdbcClient.class);
final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class);
final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class);
+ final MemberFilterCriteria criteria = emptyCriteria();
final Member member = Member.builder()
.id(UUID.randomUUID())
.firstName("Alex")
@@ -30,14 +32,125 @@ void getMembersReturnsRowsFromDatabase() {
.active(true)
.build();
- when(jdbc.sql(GET_MEMBERS_SQL)).thenReturn(statement);
+ 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.list()).thenReturn(List.of(member));
- final List result = new MemberSqlRepo(jdbc).getMembers();
+ final List result = new MemberSqlRepo(jdbc).getMembersByFilters(criteria);
assertEquals(List.of(member), result);
- verify(jdbc).sql(GET_MEMBERS_SQL);
+ verify(jdbc).sql(ArgumentMatchers.anyString());
+ verify(statement).param("page_size", MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+ verify(statement).param("offset", 0L);
+ verify(query).list();
+ }
+
+ @Test
+ void getMembersByFiltersAppliesEveryProvidedCriterion() {
+ final JdbcClient jdbc = mock(JdbcClient.class);
+ final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class);
+ final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class);
+ final MemberFilterCriteria criteria = new MemberFilterCriteria(
+ Optional.of("Alex"),
+ Optional.of("Morgan"),
+ Optional.of("alex@example.com"),
+ Optional.of(true),
+ Optional.of("Peer"),
+ Optional.of("Technology"),
+ Optional.of("Engineering"),
+ Optional.of("Community"),
+ 3,
+ 20);
+
+ when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement);
+ when(statement.params(ArgumentMatchers.anyMap())).thenReturn(statement);
+ when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any()))
+ .thenReturn(statement);
+ when(statement.query(ArgumentMatchers.>any())).thenReturn(query);
+ when(query.list()).thenReturn(List.of());
+
+ final List result = new MemberSqlRepo(jdbc).getMembersByFilters(criteria);
+
+ assertEquals(List.of(), result);
+ verify(jdbc).sql(ArgumentMatchers.anyString());
+ verify(statement)
+ .params(Map.of(
+ "first_name", "Alex",
+ "last_name", "Morgan",
+ "email", "alex@example.com",
+ "active", true,
+ "match_pref", "Peer",
+ "industry_pref", "Technology",
+ "role_pref", "Engineering",
+ "topics", "Community"));
+ verify(statement).param("page_size", 20);
+ verify(statement).param("offset", 40L);
verify(query).list();
}
+
+ @Test
+ void getMembersByFiltersOnlyIncludesProvidedCriteriaInWhereClause() {
+ final JdbcClient jdbc = mock(JdbcClient.class);
+ final JdbcClient.StatementSpec statement = mock(JdbcClient.StatementSpec.class);
+ final JdbcClient.MappedQuerySpec query = mock(JdbcClient.MappedQuerySpec.class);
+ final MemberFilterCriteria criteria = new MemberFilterCriteria(
+ Optional.of("Alex"),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of(true),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of("Community"),
+ MemberFilterCriteria.DEFAULT_PAGE,
+ MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+ final ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class);
+
+ when(jdbc.sql(ArgumentMatchers.anyString())).thenReturn(statement);
+ when(statement.params(ArgumentMatchers.anyMap())).thenReturn(statement);
+ when(statement.param(ArgumentMatchers.anyString(), ArgumentMatchers.any()))
+ .thenReturn(statement);
+ when(statement.query(ArgumentMatchers.>any())).thenReturn(query);
+ when(query.list()).thenReturn(List.of());
+
+ new MemberSqlRepo(jdbc).getMembersByFilters(criteria);
+
+ verify(jdbc).sql(sqlCaptor.capture());
+ assertEquals("""
+ SELECT
+ *
+ FROM
+ members
+ WHERE
+ LOWER(first_name) = LOWER(:first_name)
+ AND
+ active = :active
+ AND
+ LOWER(topics) = LOWER(:topics)
+ ORDER BY
+ created_at DESC,
+ id
+ LIMIT
+ :page_size
+ OFFSET
+ :offset
+ """, sqlCaptor.getValue());
+ verify(statement).params(Map.of("first_name", "Alex", "active", true, "topics", "Community"));
+ }
+
+ private static MemberFilterCriteria emptyCriteria() {
+ return new MemberFilterCriteria(
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ MemberFilterCriteria.DEFAULT_PAGE,
+ MemberFilterCriteria.DEFAULT_PAGE_SIZE);
+ }
}