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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions js/src/features/member-profile/MemberProfile.page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MemberProfilePage } from "@/features/member-profile/MemberProfile.page";
import { renderWithProviders, screen } from "@/lib/test/render";
import { Route, Routes } from "react-router-dom";
import { expect, test } from "vitest";

// The self-service endpoint always acts on the signed-in user, so a toggle here would
// let an admin deactivate their own account while looking at someone else's profile.
test("does not offer the self-service opt-out when viewing a member's profile", async () => {
renderWithProviders(
<Routes>
<Route path="/admin/members/:id" element={<MemberProfilePage />} />
</Routes>,
{ route: "/admin/members/b3a1c2d4-0000-4000-8000-000000000002" },
);

expect(await screen.findByDisplayValue("Ann")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Deactivate" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Reactivate" }),
).not.toBeInTheDocument();
});
160 changes: 160 additions & 0 deletions js/src/features/member-profile/MyProfile.page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { memberSession, sessionResponse } from "@/features/auth/api/auth.mock";
import {
memberProfile,
memberProfileResponse,
} from "@/features/member-profile/api/member-profile.mock";
import { MyProfilePage } from "@/features/member-profile/MyProfile.page";
import { MemberProfile } from "@/features/member-profile/types";
import {
renderWithProviders,
screen,
waitFor,
within,
} from "@/lib/test/render";
import { server } from "@/lib/test/server";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { beforeEach, expect, test } from "vitest";

const REASON_LABEL = "Why do you want to opt out of PatChats? (optional)";

beforeEach(() => {
server.use(http.get("/api/session", () => sessionResponse(memberSession)));
});

function respondWithProfile(overrides: Partial<MemberProfile>) {
server.use(
http.get("/api/members/:id", () =>
memberProfileResponse({ ...memberProfile, ...overrides }),
),
);
}

/** Records every body sent to the self-service status endpoint. */
function captureStatusRequests() {
const bodies: unknown[] = [];
server.use(
http.patch("/api/members/me/status", async ({ request }) => {
const body = (await request.json()) as { active: boolean };
bodies.push(body);
return HttpResponse.json({
success: true,
message: "ok",
payload: { ...memberProfile, active: body.active },
});
}),
);
return bodies;
}

test("offers an active member the option to deactivate their own profile", async () => {
renderWithProviders(<MyProfilePage />);

expect(
await screen.findByRole("button", { name: "Deactivate" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Reactivate" }),
).not.toBeInTheDocument();
});

test("deactivating asks for an optional reason in a confirm modal", async () => {
const user = userEvent.setup();
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Deactivate" }));

const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Deactivate member")).toBeInTheDocument();
expect(within(dialog).getByLabelText(REASON_LABEL)).toBeInTheDocument();
});

test("confirming deactivation sends the typed reason and flips the button to reactivate", async () => {
const user = userEvent.setup();
const bodies = captureStatusRequests();
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Deactivate" }));
const dialog = await screen.findByRole("dialog");
await user.type(within(dialog).getByLabelText(REASON_LABEL), "Moving abroad");
await user.click(within(dialog).getByRole("button", { name: "Deactivate" }));

await waitFor(() =>
expect(bodies).toEqual([
{ active: false, deactivationReason: "Moving abroad" },
]),
);
expect(
await screen.findByRole("button", { name: "Reactivate" }),
).toBeInTheDocument();
expect(
await screen.findByText("Your profile is now inactive."),
).toBeInTheDocument();
});

test("deactivating without typing a reason sends no reason at all", async () => {
const user = userEvent.setup();
const bodies = captureStatusRequests();
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Deactivate" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Deactivate" }));

await waitFor(() => expect(bodies).toEqual([{ active: false }]));
});

test("reactivating skips the reason prompt, sends only the status, and flips the button back", async () => {
const user = userEvent.setup();
const bodies = captureStatusRequests();
respondWithProfile({ active: false, deactivationReason: "Moving abroad" });
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Reactivate" }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Reactivate member")).toBeInTheDocument();
expect(within(dialog).queryByLabelText(REASON_LABEL)).not.toBeInTheDocument();
await user.click(within(dialog).getByRole("button", { name: "Reactivate" }));

await waitFor(() => expect(bodies).toEqual([{ active: true }]));
expect(
await screen.findByRole("button", { name: "Deactivate" }),
).toBeInTheDocument();
expect(
await screen.findByText("Your profile is now active."),
).toBeInTheDocument();
});

test("cancelling the confirm modal makes no request", async () => {
const user = userEvent.setup();
const bodies = captureStatusRequests();
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Deactivate" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));

await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(bodies).toHaveLength(0);
});

test("a failed update shows an error and leaves the profile active", async () => {
const user = userEvent.setup();
server.use(
http.patch("/api/members/me/status", () =>
HttpResponse.json({ success: false, message: "boom" }, { status: 500 }),
),
);
renderWithProviders(<MyProfilePage />);

await user.click(await screen.findByRole("button", { name: "Deactivate" }));
const dialog = await screen.findByRole("dialog");
await user.click(within(dialog).getByRole("button", { name: "Deactivate" }));

expect(await screen.findByText("Update failed")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Reactivate" }),
).not.toBeInTheDocument();
});
49 changes: 49 additions & 0 deletions js/src/features/member-profile/api/member-profile.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { memberSession } from "@/features/auth/api/auth.mock";
import { MemberProfile } from "@/features/member-profile/types";
import { http, HttpResponse } from "msw";

/** The signed-in member's own profile (same id as `memberSession`). */
export const memberProfile: MemberProfile = {
id: memberSession.id,
active: true,
firstName: "Ann",
lastName: "Example",
email: "ann@example.com",
linkedInUrl: "https://www.linkedin.com/in/ann-example",
introduction: "Ann is a designer who loves mentoring.",
referralSource: "Patina Network",
matchPref: "Peer",
industryPref: "Technology",
rolePref: "Design",
topics: "Community",
extraNotes: "",
createdAt: "2026-01-15T14:30:00Z",
updatedAt: "2026-01-15T14:30:00Z",
};

export const memberProfileResponse = (profile: MemberProfile) =>
HttpResponse.json({
success: true,
message: "Member retrieved successfully",
payload: profile,
});

export const memberProfileHandlers = [
http.get("/api/members/:id", ({ params }) =>
memberProfileResponse({ ...memberProfile, id: String(params.id) }),
),
http.patch("/api/members/me/status", async ({ request }) => {
const { active, deactivationReason } = (await request.json()) as {
active: boolean;
deactivationReason?: string;
};
return HttpResponse.json({
success: true,
message:
active ?
"Membership reactivated successfully"
: "Membership deactivated successfully",
payload: { ...memberProfile, active, deactivationReason },
});
}),
];
2 changes: 2 additions & 0 deletions js/src/lib/test/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authHandlers } from "@/features/auth/api/auth.mock";
import { memberProfileHandlers } from "@/features/member-profile/api/member-profile.mock";
import { membersHandlers } from "@/features/members/api/members.mock";
import { sampleHandlers } from "@/features/sample/api/sample.mock";
import { setupServer } from "msw/node";
Expand All @@ -10,5 +11,6 @@ import { setupServer } from "msw/node";
export const server = setupServer(
...sampleHandlers,
...authHandlers,
...memberProfileHandlers,
...membersHandlers,
);
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public static MemberDto from(final Member member) {
.rolePref(member.getRolePref())
.topics(member.getTopics())
.extraNotes(member.getExtraNotes())
.deactivationReason(member.getDeactivationReason())
.createdAt(member.getCreatedAt())
.updatedAt(member.getUpdatedAt())
.build();
Expand Down
Loading
Loading