diff --git a/.github/scripts/src/db/migrate.ts b/.github/scripts/src/db/migrate.ts
index a325560..41dc5e5 100644
--- a/.github/scripts/src/db/migrate.ts
+++ b/.github/scripts/src/db/migrate.ts
@@ -26,7 +26,7 @@ async function main() {
DATABASE_PORT,
DATABASE_USER,
DATABASE_PASSWORD,
- })`./mvnw flyway:migrate -Dflyway.locations=filesystem:db`;
+ })`./mvnw flyway:migrate -Dflyway.locations=filesystem:db/migration`;
}
function parseCiEnv(environment: string) {
diff --git a/Justfile b/Justfile
index b29dda3..bcf7969 100644
--- a/Justfile
+++ b/Justfile
@@ -2,11 +2,11 @@ set shell := ["bash", "-uc"]
# Migrate to local DB
migrate *args:
- dotenvx run -- ./mvnw flyway:migrate -Dflyway.locations=filesystem:./db {{args}}
+ dotenvx run -- ./mvnw flyway:migrate -Dflyway.locations=filesystem:./db/migration,filesystem:./db/repeated {{args}}
# Drop local DB
drop *args:
- dotenvx run -- ./mvnw flyway:clean -Dflyway.locations=filesystem:./db -Dflyway.cleanDisabled=false {{args}}
+ dotenvx run -- ./mvnw flyway:clean -Dflyway.locations=filesystem:./db/migration,filesystem:./db/repeated -Dflyway.cleanDisabled=false {{args}}
# Run the backend Spring server
backend-dev *args:
diff --git a/db/README.md b/db/README.md
index 3ce1804..79f66cf 100644
--- a/db/README.md
+++ b/db/README.md
@@ -100,7 +100,7 @@ Versioned migrations are under `db/migration/`
- Versioned migrations will only run once.
- You can use these migrations to define tables, schemas, columns (otherwise known as DDL) OR define insertions/updates/deletes of certain columns (otherwise known as DML).
-> If you need to generate mock data **that does not need to be in production**, please look at the docs on [repeatable migrations](#repeatable-migrations)
+> If you need mock data **only for local development**, see [local repeatable migrations](#local-repeatable-migrations).
### Naming Scheme
@@ -123,18 +123,17 @@ V0642__Insert_new_tag_enums.SQL
V9999__Delete_user_table.SQL
```
-## Repeatable Migrations
+## Local Repeatable Migrations
-Repeated migrations are under `db/repeated/`
+Local repeatable migrations are under `db/repeated/`.
> [!NOTE]
-> Repeatable migrations are only applied to local & CI databases.
-> Repeatable migrations are **NOT** applied to the production & staging database.
+> `just migrate` explicitly includes this directory. Production and staging migrations only scan `db/migration`, so local seed data is never applied to remote databases.
### Explanation
1. Instead of being run just once, repeatable migrations are (re-)applied to a database on [migrate](https://documentation.red-gate.com/fd/migrate-277578887.html) every time their checksum changes.
-1. Our main use for repeatable migrations are to generate mock data to use locally and in our CI database, but **is not needed for our production or staging database**.
+1. These migrations generate mock data for local development and are excluded from production and staging migrations.
#### Requirements
diff --git a/db/repeated/R__Mock_V0001_Insert_test_members.sql b/db/repeated/R__Mock_V0001_Insert_test_members.sql
new file mode 100644
index 0000000..ccc5a52
--- /dev/null
+++ b/db/repeated/R__Mock_V0001_Insert_test_members.sql
@@ -0,0 +1,167 @@
+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
+ (
+ '10000000-0000-4000-8000-000000000001',
+ 'Avery',
+ 'Chen',
+ 'avery.chen@example.com',
+ 'https://www.linkedin.com/in/avery-chen-example',
+ 'Product designer interested in making financial tools easier to use.',
+ 'Patina Network newsletter',
+ TRUE,
+ 'Peer - I want to meet someone with similar experience',
+ 'Financial Technology',
+ 'Product Designer',
+ 'Design systems, Financial literacy, Career growth',
+ 'Available for coffee in Manhattan or a virtual chat.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000002',
+ 'Jordan',
+ 'Williams',
+ 'jordan.williams@example.com',
+ 'https://www.linkedin.com/in/jordan-williams-example',
+ 'Backend engineer building reliable systems for healthcare teams.',
+ 'Friend',
+ TRUE,
+ 'Mentor - I am looking for guidance from someone with more experience',
+ 'Healthcare Technology',
+ 'Software Engineer',
+ 'Distributed systems, Leadership, Open source',
+ 'Usually available after work on Tuesdays and Thursdays.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000003',
+ 'Priya',
+ 'Patel',
+ 'priya.patel@example.com',
+ 'https://www.linkedin.com/in/priya-patel-example',
+ 'Founder working on software that helps small businesses measure emissions.',
+ 'Patina Network event',
+ TRUE,
+ 'Peer - I want to exchange ideas with another builder',
+ 'Climate Technology',
+ 'Founder',
+ 'Fundraising, Sustainability, Product strategy',
+ 'Happy to meet virtually or in Brooklyn.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000004',
+ 'Marcus',
+ 'Johnson',
+ 'marcus.johnson@example.com',
+ NULL,
+ 'Data analyst focused on improving outcomes for public school students.',
+ 'Colleague',
+ FALSE,
+ 'Mentor - I can offer guidance to someone earlier in their career',
+ 'Education',
+ 'Data Analyst',
+ 'Analytics, Public policy, Mentorship',
+ 'Taking a break from matching this month.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000005',
+ 'Sofia',
+ 'Rodriguez',
+ 'sofia.rodriguez@example.com',
+ 'https://www.linkedin.com/in/sofia-rodriguez-example',
+ 'Community program director connecting volunteers with neighborhood nonprofits.',
+ 'Community partner',
+ TRUE,
+ 'Open - I am happy to meet a peer, mentor, or mentee',
+ 'Nonprofit',
+ 'Program Director',
+ 'Community building, Social impact, Volunteer engagement',
+ 'Prefers an in-person conversation when possible.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000006',
+ 'Ethan',
+ 'Kim',
+ 'ethan.kim@example.com',
+ 'https://www.linkedin.com/in/ethan-kim-example',
+ 'Graduate student researching responsible machine learning applications.',
+ 'University alumni group',
+ TRUE,
+ 'Mentor - I am looking for guidance from someone with more experience',
+ 'Artificial Intelligence',
+ 'Graduate Researcher',
+ 'Responsible AI, Research careers, Entrepreneurship',
+ NULL
+ ),
+ (
+ '10000000-0000-4000-8000-000000000007',
+ 'Nia',
+ 'Thompson',
+ 'nia.thompson@example.com',
+ NULL,
+ 'Marketing strategist helping independent creators grow sustainable audiences.',
+ 'LinkedIn',
+ TRUE,
+ 'Peer - I want to meet someone with similar experience',
+ 'Media',
+ 'Marketing Strategist',
+ 'Brand strategy, Creator economy, Storytelling',
+ 'Virtual chats work best during weekday mornings.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000008',
+ 'Leo',
+ 'Martinez',
+ 'leo.martinez@example.com',
+ 'https://www.linkedin.com/in/leo-martinez-example',
+ 'Operations manager interested in technology-enabled hospitality experiences.',
+ 'Friend',
+ TRUE,
+ 'Open - I am happy to meet a peer, mentor, or mentee',
+ 'Hospitality',
+ 'Operations Manager',
+ 'Operations, Customer experience, Small business',
+ 'Available in Queens or by video call.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000009',
+ 'Maya',
+ 'Brooks',
+ 'maya.brooks@example.com',
+ 'https://www.linkedin.com/in/maya-brooks-example',
+ 'People operations leader building inclusive teams at growing companies.',
+ 'Patina Network event',
+ TRUE,
+ 'Mentor - I can offer guidance to someone earlier in their career',
+ 'Professional Services',
+ 'People Operations Director',
+ 'Inclusive hiring, Management, Organizational design',
+ 'Open to meeting anywhere near a major subway line.'
+ ),
+ (
+ '10000000-0000-4000-8000-000000000010',
+ 'Noah',
+ 'Okafor',
+ 'noah.okafor@example.com',
+ NULL,
+ 'Independent consultant helping local retailers modernize their operations.',
+ 'Community partner',
+ FALSE,
+ 'Peer - I want to exchange ideas with another builder',
+ 'Retail',
+ 'Business Consultant',
+ 'Digital transformation, Consulting, Local business',
+ 'Inactive seed record for testing member status filters.'
+ )
+ON CONFLICT (email) DO NOTHING;
diff --git a/js/src/app/router/router.tsx b/js/src/app/router/router.tsx
index bb70f14..e9b77f3 100644
--- a/js/src/app/router/router.tsx
+++ b/js/src/app/router/router.tsx
@@ -7,6 +7,7 @@ import AdminPage from "@/features/admin/Admin.page";
import AdminLoginPage from "@/features/admin/AdminLogin.page";
import EmailAdminPage from "@/features/emails/EmailAdminPage";
import HomePage from "@/features/home/Home.page";
+import MembersPage from "@/features/members/Members.page";
import SamplePage from "@/features/sample/Sample.page";
import SampleAdminPage from "@/features/sample/SampleAdmin.page";
import { SignUpPage } from "@/features/sign-up/SignUp.page";
@@ -41,6 +42,7 @@ export const router = createBrowserRouter([
element: ,
children: [
{ path: "admin", element: },
+ { path: "admin/members", element: },
{ path: "admin/email", element: },
{ path: "sample/admin", element: },
],
diff --git a/js/src/features/members/Members.page.test.tsx b/js/src/features/members/Members.page.test.tsx
new file mode 100644
index 0000000..a877713
--- /dev/null
+++ b/js/src/features/members/Members.page.test.tsx
@@ -0,0 +1,32 @@
+import MembersPage from "@/features/members/Members.page";
+import { renderWithProviders, screen } from "@/lib/test/render";
+import { server } from "@/lib/test/server";
+import { http, HttpResponse } from "msw";
+import { expect, test } from "vitest";
+
+test("renders every member returned by the API", async () => {
+ renderWithProviders();
+
+ expect(await screen.findByText("Alex Morgan")).toBeInTheDocument();
+ expect(screen.getByText("Jordan Lee")).toBeInTheDocument();
+ expect(screen.getByText("2 members")).toBeInTheDocument();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText("Inactive")).toBeInTheDocument();
+});
+
+test("renders an empty state when there are no members", async () => {
+ server.use(
+ http.get("/api/members", () =>
+ HttpResponse.json({
+ message: "Members retrieved successfully",
+ payload: [],
+ success: true,
+ }),
+ ),
+ );
+
+ renderWithProviders();
+
+ expect(await screen.findByText("No members found.")).toBeInTheDocument();
+ expect(screen.getByText("0 members")).toBeInTheDocument();
+});
diff --git a/js/src/features/members/Members.page.tsx b/js/src/features/members/Members.page.tsx
new file mode 100644
index 0000000..616e168
--- /dev/null
+++ b/js/src/features/members/Members.page.tsx
@@ -0,0 +1,114 @@
+import { useMembers } from "@/features/members/api/useMembers";
+import {
+ Alert,
+ Anchor,
+ Badge,
+ Center,
+ Group,
+ Loader,
+ Paper,
+ Stack,
+ Table,
+ Text,
+ Title,
+} from "@mantine/core";
+
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ dateStyle: "medium",
+});
+
+export default function MembersPage() {
+ const { data: members, isError, isPending } = useMembers();
+
+ if (isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ Try refreshing the page. If the problem continues, check that the
+ backend is running.
+
+ );
+ }
+
+ return (
+
+
+
+
Members
+ Everyone registered for PatChats.
+
+
+ {members.length} {members.length === 1 ? "member" : "members"}
+
+
+ {members.length === 0 ?
+
+
+ No members found.
+
+
+ :
+
+
+
+ Name
+ Email
+ LinkedIn
+ Status
+ Match preference
+ Industry
+ Role
+ Topics
+ Joined
+
+
+
+ {members.map((member) => (
+
+
+ {member.firstName} {member.lastName}
+
+
+
+ {member.email}
+
+
+
+ {member.linkedInUrl ?
+
+ Profile
+
+ : "—"}
+
+
+
+ {member.active ? "Active" : "Inactive"}
+
+
+ {member.matchPref ?? "—"}
+ {member.industryPref ?? "—"}
+ {member.rolePref ?? "—"}
+ {member.topics ?? "—"}
+
+ {dateFormatter.format(new Date(member.createdAt))}
+
+
+ ))}
+
+
+
+ }
+
+ );
+}
diff --git a/js/src/features/members/api/members.mock.ts b/js/src/features/members/api/members.mock.ts
new file mode 100644
index 0000000..3ade4da
--- /dev/null
+++ b/js/src/features/members/api/members.mock.ts
@@ -0,0 +1,46 @@
+import { http, HttpResponse } from "msw";
+
+export const membersHandlers = [
+ http.get("/api/members", () =>
+ HttpResponse.json({
+ message: "Members retrieved successfully",
+ payload: [
+ {
+ active: true,
+ createdAt: "2026-01-15T14:30:00Z",
+ email: "alex@example.com",
+ extraNotes: null,
+ firstName: "Alex",
+ id: "50ecf8a0-6345-40f8-b59f-438c3f338b82",
+ industryPref: "Technology",
+ introduction: "I build community-focused software.",
+ lastName: "Morgan",
+ linkedInUrl: "https://www.linkedin.com/in/alex-morgan",
+ matchPref: "Peer",
+ referralSource: "Patina Network",
+ rolePref: "Engineering",
+ topics: "Community, Technology",
+ updatedAt: "2026-01-15T14:30:00Z",
+ },
+ {
+ active: false,
+ createdAt: "2025-12-10T09:00:00Z",
+ email: "jordan@example.com",
+ extraNotes: "Prefers virtual chats.",
+ firstName: "Jordan",
+ id: "db827ce4-5ed1-4649-98ca-3e5fb538d22e",
+ industryPref: "Design",
+ introduction: "I am a product designer.",
+ lastName: "Lee",
+ linkedInUrl: null,
+ matchPref: "Mentor",
+ referralSource: null,
+ rolePref: "Product design",
+ topics: "Design systems",
+ updatedAt: "2026-02-01T11:00:00Z",
+ },
+ ],
+ success: true,
+ }),
+ ),
+];
diff --git a/js/src/features/members/api/useMembers.ts b/js/src/features/members/api/useMembers.ts
new file mode 100644
index 0000000..b9fe7d4
--- /dev/null
+++ b/js/src/features/members/api/useMembers.ts
@@ -0,0 +1,39 @@
+import { apiFetch } from "@/lib/api/client";
+import { useQuery } from "@tanstack/react-query";
+
+export interface Member {
+ active: boolean;
+ createdAt: string;
+ email: string;
+ extraNotes: string | null;
+ firstName: string;
+ id: string;
+ industryPref: string | null;
+ introduction: string;
+ lastName: string;
+ linkedInUrl: string | null;
+ matchPref: string | null;
+ referralSource: string | null;
+ rolePref: string | null;
+ topics: string | null;
+ updatedAt: string;
+}
+
+interface MembersResponse {
+ message: string;
+ payload: Member[];
+ success: boolean;
+}
+
+export const membersQueryKey = ["members"] as const;
+
+/** Fetches every member for the admin members page. */
+export function useMembers() {
+ return useQuery({
+ queryKey: membersQueryKey,
+ queryFn: async () => {
+ const response = await apiFetch("/members");
+ return response.payload;
+ },
+ });
+}
diff --git a/js/src/lib/api/useSession.ts b/js/src/lib/api/useSession.ts
index f48ec69..bcbcfc6 100644
--- a/js/src/lib/api/useSession.ts
+++ b/js/src/lib/api/useSession.ts
@@ -19,7 +19,20 @@ export const sessionQueryKey = ["session"] as const;
export function useSession() {
return useQuery({
queryKey: sessionQueryKey,
- queryFn: () => apiFetch("/session"),
+ queryFn: () => {
+ // Authentication is not wired yet. Local development uses an admin
+ // identity so guarded pages can be exercised; production still fails
+ // closed when the session endpoint is unavailable.
+ if (import.meta.env.DEV) {
+ return Promise.resolve({
+ id: "local-dev-admin",
+ isAdmin: true,
+ name: "Local admin",
+ });
+ }
+
+ return apiFetch("/session");
+ },
retry: false,
staleTime: Infinity,
});
diff --git a/js/src/lib/test/server.ts b/js/src/lib/test/server.ts
index 3c2f213..f51ed1c 100644
--- a/js/src/lib/test/server.ts
+++ b/js/src/lib/test/server.ts
@@ -1,3 +1,4 @@
+import { membersHandlers } from "@/features/members/api/members.mock";
import { sampleHandlers } from "@/features/sample/api/sample.mock";
import { setupServer } from "msw/node";
@@ -5,4 +6,4 @@ import { setupServer } from "msw/node";
* MSW server for tests. Each domain owns its request handlers in
* `features//api/.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 3b9bab1..4191a96 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/member/MemberController.java b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
index 1d615d6..d2d67b7 100644
--- a/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
+++ b/src/main/java/org/patinanetwork/patchats/api/member/MemberController.java
@@ -1,12 +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 java.util.List;
import lombok.RequiredArgsConstructor;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
import org.patinanetwork.patchats.api.member.dto.MemberDto;
import org.patinanetwork.patchats.common.dto.ApiResponder;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -26,6 +29,13 @@ public ResponseEntity> createMember(@Valid @RequestBody
return ResponseEntity.ok(ApiResponder.success("Member created successfully", response));
}
+ @GetMapping
+ @Operation(summary = "List all members", description = "Returns every member ordered by creation date.")
+ public ResponseEntity>> getMembers() {
+ final List response = memberService.getMembers();
+ return ResponseEntity.ok(ApiResponder.success("Members retrieved successfully", response));
+ }
+
// TODO: Implement these endpoints after createMember is fully functional and tested
// @PatchMapping("/{id}")
// public ResponseEntity> updateMember(
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 8532e87..2712802 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberControllerTest.java
@@ -2,10 +2,12 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.patinanetwork.patchats.api.member.dto.CreateMemberRequest;
@@ -62,11 +64,9 @@ void createMemberReturnsOkAndMemberDto() 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()))
@@ -86,11 +86,9 @@ void createMemberReturnsOkAndMemberDto() 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));
}
@@ -98,12 +96,55 @@ 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));
}
+
+ @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.getMembers()).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"));
+ }
+
+ 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);
+ }
}
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 8bf3318..d99e679 100644
--- a/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
+++ b/src/test/java/org/patinanetwork/patchats/api/member/MemberServiceTest.java
@@ -10,6 +10,7 @@
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;
@@ -113,7 +114,7 @@ void createMemberThrowsExceptionWhenEmailAlreadyExists() {
}
@Test
- void updateMember_throwsExceptionWhenMemberNotFound() {
+ void updateMemberThrowsExceptionWhenMemberNotFound() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = new UpdateMemberRequest(
"UpdatedFirstName",
@@ -134,7 +135,7 @@ void updateMember_throwsExceptionWhenMemberNotFound() {
}
@Test
- void updateMember_successWithOnlyNameField() {
+ void updateMemberSuccessWithOnlyNameField() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request =
new UpdateMemberRequest("UpdatedFirstName", null, null, null, null, null, null, null, null, null);
@@ -179,7 +180,7 @@ void updateMember_successWithOnlyNameField() {
}
@Test
- void updateMember_successWithAllNullFields() {
+ void updateMemberSuccessWithAllNullFields() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request =
new UpdateMemberRequest(null, null, null, null, null, null, null, null, null, null);
@@ -211,7 +212,7 @@ void updateMember_successWithAllNullFields() {
}
@Test
- void updateMember_successWithAllFields() {
+ void updateMemberSuccessWithAllFields() {
final UUID id = UUID.randomUUID();
final UpdateMemberRequest request = new UpdateMemberRequest(
"UpdatedFirstName",
@@ -269,4 +270,31 @@ void updateMember_successWithAllFields() {
assertEquals(request.topics(), response.getTopics());
assertEquals(request.extraNotes(), response.getExtraNotes());
}
+
+ @Test
+ void getMembersReturnsEveryMemberAsDto() {
+ 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.getMembers()).thenReturn(List.of(firstMember, secondMember));
+
+ final List response = memberService.getMembers();
+
+ 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());
+ }
}