Skip to content
Open
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
3 changes: 2 additions & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"cleanup-test-data": "bun run src/scripts/cleanup-test-data.ts",
"test-webhook": "bun run src/scripts/test-stripe-webhook.ts",
"sync-profile-timestamps": "bun run src/scripts/sync-profile-timestamps.ts",
"seed-claim-test": "bun run src/scripts/seed-claim-test.ts"
"seed-claim-test": "bun run src/scripts/seed-claim-test.ts",
"backfill-allow-profile-editing": "bun run src/scripts/backfill-allow-profile-editing.ts"
},
"engines": {
"node": "24"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ActivateMembershipApiResponseSchema,
ActivateMembershipBodySchema,
ApproveProfileApiResponseSchema,
ApproveProfileBodySchema,
CancelMembershipApiResponseSchema,
CleanSlateApiResponseSchema,
DeleteDraftProfileApiResponseSchema,
Expand Down Expand Up @@ -191,18 +192,20 @@ export function createAdminMembersPlugin(services?: PartialServices) {
response: ToggleProfileDraftApiResponseSchema,
},
)
// POST /:memberId/profile/approve - Approve member for profile work (served at /api/admin/members/:memberId/profile/approve)
// POST /:memberId/profile/approve - Enable or disable profile editing permission (served at /api/admin/members/:memberId/profile/approve)
.post(
"/profile/approve",
async ({ params, adminToken, memberAdminService, logger, set }) =>
async ({ params, body, adminToken, memberAdminService, logger, set }) =>
approveProfileLogic({
memberId: params.memberId,
allowProfileEditing: body.allowProfileEditing,
adminUid: getAdminUid(adminToken, logger),
memberAdminService,
logger,
set,
}),
{
body: ApproveProfileBodySchema,
response: ApproveProfileApiResponseSchema,
},
)
Expand Down
203 changes: 127 additions & 76 deletions functions/src/admin-members-api/routes/approve-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import {
} from "../../shared-api/errors/http-error.js";
import { handleRequest } from "../../test-utils/handle-request.js";
import type { MemberDocument } from "../../types/member-document.js";
import type { ApproveProfileResult } from "../services/approve-profile.js";
import type { SetProfileEditingPermissionResult } from "../services/approve-profile.js";
import { createAdminTestPlugin } from "../test-utils/create-admin-test-plugin.js";

describe("POST /:memberId/profile/approve", () => {
interface SetupOptions {
body?: Record<string, unknown>;
memberId?: string;
authToken?: string | null;
memberNotFound?: boolean;
Expand All @@ -19,22 +20,21 @@ describe("POST /:memberId/profile/approve", () => {
}

function setup({
body = { allowProfileEditing: true },
memberId = "test-member-id",
authToken = "admin-token",
memberNotFound = false,
serverError = false,
isAdminLookupFails = false,
}: SetupOptions = {}) {
const defaultMember: MemberDocument = {
uid: memberId,
email: "member@example.com",
createdAt: Timestamp.now(),
membershipActive: true,
profileApprovedAt: Timestamp.now(),
};

const mockApproveProfile = mock(
(): Promise<ApproveProfileResult> => {
({
memberId: approvedMemberId,
allowProfileEditing,
}: {
memberId: string;
allowProfileEditing: boolean;
}): Promise<SetProfileEditingPermissionResult> => {
if (memberNotFound) {
return Promise.reject(
new NotFoundError(`Member with ID ${memberId} not found`),
Expand All @@ -43,7 +43,14 @@ describe("POST /:memberId/profile/approve", () => {
if (serverError) {
return Promise.reject(new Error("Firestore unavailable"));
}
return Promise.resolve({ member: defaultMember });
const member: MemberDocument = {
uid: approvedMemberId,
email: "member@example.com",
createdAt: Timestamp.now(),
membershipActive: true,
allowProfileEditing,
};
return Promise.resolve({ member });
},
);

Expand All @@ -59,100 +66,144 @@ describe("POST /:memberId/profile/approve", () => {
},
});

const headers: Record<string, string> = {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (authToken) {
headers["Authorization"] = `Bearer ${authToken}`;
}

const request = new Request(
`http://localhost/${memberId}/profile/approve`,
{
method: "POST",
headers,
},
);
const request = new Request(`http://localhost/${memberId}/profile/approve`, {
method: "POST",
headers,
body: JSON.stringify(body),
});

return { testApp, request };
}

it("should return 401 when no authorization header is provided", async () => {
const { testApp, request } = setup({ authToken: null });
describe("Authentication", () => {
it("should return 401 when no authorization header is provided", async () => {
const { testApp, request } = setup({ authToken: null });

const response = await handleRequest(testApp, request);

const response = await handleRequest(testApp, request);
expect(response.status).toBe(401);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("Missing Authorization header");
});

it("should return 403 when non-admin user tries to approve profile work", async () => {
const { testApp, request } = setup({ authToken: "non-admin-token" });

expect(response.status).toBe(401);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("Missing Authorization header");
const response = await handleRequest(testApp, request);

expect(response.status).toBe(403);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("Admin privileges required");
});
});

it("should return 403 when non-admin user tries to approve profile work", async () => {
const { testApp, request } = setup({ authToken: "non-admin-token" });
describe("Input validation", () => {
it("should return 422 when allowProfileEditing is missing", async () => {
const { testApp, request } = setup({ body: {} });

const response = await handleRequest(testApp, request);

expect(response.status).toBe(422);
});

const response = await handleRequest(testApp, request);
it("should return 422 when allowProfileEditing is not a boolean", async () => {
const { testApp, request } = setup({
body: { allowProfileEditing: "yes" },
});

expect(response.status).toBe(403);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("Admin privileges required");
const response = await handleRequest(testApp, request);

expect(response.status).toBe(422);
});
});

it("should return success with updated member data", async () => {
const { testApp, request } = setup();
describe("Success", () => {
it("should return success with updated member data", async () => {
const { testApp, request } = setup();

const response = await handleRequest(testApp, request);

expect(response.status).toBe(200);
const body = (await response.json()) as {
success?: boolean;
member?: {
uid?: string;
email?: string;
allowProfileEditing?: boolean;
isAdmin?: boolean;
};
};
expect(body.success).toBe(true);
expect(body.member?.uid).toBe("test-member-id");
expect(body.member?.email).toBe("member@example.com");
expect(body.member?.allowProfileEditing).toBe(true);
expect(body.member?.isAdmin).toBe(false);
});

const response = await handleRequest(testApp, request);
it("should return success when profile editing is disabled", async () => {
const { testApp, request } = setup({
body: { allowProfileEditing: false },
});

expect(response.status).toBe(200);
const body = (await response.json()) as {
success?: boolean;
member?: {
uid?: string;
email?: string;
profileApprovedAt?: string;
isAdmin?: boolean;
const response = await handleRequest(testApp, request);

expect(response.status).toBe(200);
const body = (await response.json()) as {
success?: boolean;
member?: {
allowProfileEditing?: boolean;
};
};
};
expect(body.success).toBe(true);
expect(body.member?.uid).toBe("test-member-id");
expect(body.member?.email).toBe("member@example.com");
expect(body.member?.profileApprovedAt).toBeDefined();
expect(body.member?.isAdmin).toBe(false);
});
expect(body.success).toBe(true);
expect(body.member?.allowProfileEditing).toBe(false);
});

it("should still return success when isAdmin lookup fails after approval", async () => {
const { testApp, request } = setup({ isAdminLookupFails: true });
it("should still return success when isAdmin lookup fails after approval", async () => {
const { testApp, request } = setup({ isAdminLookupFails: true });

const response = await handleRequest(testApp, request);
const response = await handleRequest(testApp, request);

expect(response.status).toBe(200);
const body = (await response.json()) as {
success?: boolean;
member?: {
profileApprovedAt?: string;
isAdmin?: boolean;
expect(response.status).toBe(200);
const body = (await response.json()) as {
success?: boolean;
member?: {
allowProfileEditing?: boolean;
isAdmin?: boolean;
};
};
};
expect(body.success).toBe(true);
expect(body.member?.profileApprovedAt).toBeDefined();
expect(body.member?.isAdmin).toBe(false);
expect(body.success).toBe(true);
expect(body.member?.allowProfileEditing).toBe(true);
expect(body.member?.isAdmin).toBe(false);
});
});

it("should return 404 when member not found", async () => {
const { testApp, request } = setup({ memberNotFound: true });
describe("Error handling", () => {
it("should return 404 when member not found", async () => {
const { testApp, request } = setup({ memberNotFound: true });

const response = await handleRequest(testApp, request);
const response = await handleRequest(testApp, request);

expect(response.status).toBe(404);
const body = (await response.json()) as { error?: string };
expect(body.error).toContain("not found");
});
expect(response.status).toBe(404);
const body = (await response.json()) as { error?: string };
expect(body.error).toContain("not found");
});

it("should return 500 for unexpected errors", async () => {
const { testApp, request } = setup({ serverError: true });
it("should return 500 for unexpected errors", async () => {
const { testApp, request } = setup({ serverError: true });

const response = await handleRequest(testApp, request);
const response = await handleRequest(testApp, request);

expect(response.status).toBe(500);
const body = (await response.json()) as { error?: string };
expect(body.error).toBeDefined();
expect(body.error).not.toContain("Firestore unavailable");
expect(response.status).toBe(500);
const body = (await response.json()) as { error?: string };
expect(body.error).toBeDefined();
expect(body.error).not.toContain("Firestore unavailable");
});
});
});
13 changes: 9 additions & 4 deletions functions/src/admin-members-api/routes/approve-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ import { handleRouteError } from "../../shared-api/utils/route-error-handler.js"

export async function approveProfileLogic({
memberId,
allowProfileEditing,
adminUid,
memberAdminService,
logger,
set,
}: {
memberId: string;
allowProfileEditing: boolean;
adminUid: string;
memberAdminService: MemberAdminService;
logger: Logger;
set: { status?: number | string };
}): Promise<ApproveProfileApiResponse> {
try {
logger.info("Admin approving member for profile work", {
logger.info("Admin updating member profile editing permission", {
adminUid,
memberId,
});

const result = await memberAdminService.approveProfile({ memberId });
const result = await memberAdminService.approveProfile({
memberId,
allowProfileEditing,
});

let isAdmin = false;
try {
Expand All @@ -42,11 +47,11 @@ export async function approveProfileLogic({
} catch (error: unknown) {
return handleRouteError({
error,
operation: "approve member for profile work",
operation: "update member profile editing permission",
errorId: ERROR_IDS.API_ADMIN_UPDATE_MEMBER_FAILED,
logger,
set,
context: { memberId, adminUid },
context: { memberId, allowProfileEditing, adminUid },
}) as ApproveProfileApiResponse;
}
}
6 changes: 3 additions & 3 deletions functions/src/admin-members-api/routes/link-profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("POST /:memberId/profile/link", () => {
membershipExpiresAt: Timestamp.now(),
slug: "unlinked-doula-profile",
profileCreatedAt: Timestamp.now(),
profileApprovedAt: Timestamp.now(),
allowProfileEditing: true,
};

const mockLinkProfile = mock(
Expand Down Expand Up @@ -164,15 +164,15 @@ describe("POST /:memberId/profile/link", () => {
email?: string;
membershipActive?: boolean;
slug?: string;
profileApprovedAt?: string;
allowProfileEditing?: boolean;
isAdmin?: boolean;
};
};
expect(body.success).toBe(true);
expect(body.member?.uid).toBe("test-member-id");
expect(body.member?.email).toBe("member@example.com");
expect(body.member?.slug).toBe("unlinked-doula-profile");
expect(body.member?.profileApprovedAt).toBeDefined();
expect(body.member?.allowProfileEditing).toBe(true);
expect(body.member?.isAdmin).toBe(false);
});

Expand Down
Loading
Loading