From 725f7dbf169243f4986eac09cfa881b33a559830 Mon Sep 17 00:00:00 2001 From: Dimural Murat Date: Thu, 20 Nov 2025 22:21:27 -0500 Subject: [PATCH 1/4] volunteer update api --- README.md | 71 +++++++++------ src/app/api/volunteers/[id]/route.ts | 44 +++++++++ src/lib/api/index.ts | 4 + src/lib/api/updateVolunteer.ts | 129 +++++++++++++++++++++++++++ tests/lib/api/getExample.test.ts | 6 +- 5 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 src/app/api/volunteers/[id]/route.ts create mode 100644 src/lib/api/updateVolunteer.ts diff --git a/README.md b/README.md index 4694e26..5bf1d21 100644 --- a/README.md +++ b/README.md @@ -8,33 +8,39 @@ Welcome to the TRCC Volunteer Management System codebase! This guide outlines ho ### Initial Setup (One-Time) -1. **Clone the repository** +1. **Clone the repository** + - `git clone https://github.com/uoftblueprint/trcc.git` - `cd trcc` 2. **Set up environment variables** -Copy the shared `.env.local` configuration file provided by the PLs into the root directory (it should be found in `trcc/.env.local`). This file contains all necessary keys/URLs for Supabase and local development. + Copy the shared `.env.local` configuration file provided by the PLs into the root directory (it should be found in `trcc/.env.local`). This file contains all necessary keys/URLs for Supabase and local development. 3. **Install dependencies** + - `npm install` --- ### Running the App Locally -1. **Start development server** +1. **Start development server** + - Run the app at [http://localhost:3000](http://localhost:3000) - use `npm run dev` -2. **Format your code** +2. **Format your code** + - Automatically format all files according to project rules - use `npm run format` -3. **Run lint checks** -- Check code style and look for any lint issues +3. **Run lint checks** + +- Check code style and look for any lint issues - use `npm run lint` -4. **Run automated tests** +4. **Run automated tests** + - Run all available tests. Test cases will be developed throughout the sprints. - use `npm run test` @@ -43,38 +49,45 @@ Copy the shared `.env.local` configuration file provided by the PLs into the roo ## Working on a Ticket 0. **Select a ticket from the Kanban board** -To find the kanban board: go to the Github repo click on Projects > TRCC Project. + To find the kanban board: go to the Github repo click on Projects > TRCC Project. + - Choose a ticket from the "Ready" column - Move the ticket to the "In progress" column and assign yourself to the ticket 1. **Update your local main branch** -Make sure your local main branch is up to date before branching from main: + Make sure your local main branch is up to date before branching from main: + - git checkout main - git pull origin main 2. **Create a new branch** -Name your branch concisely and descriptively related to the issue/ticket: -Examples: `backend/filter-by-role-api`, `frontend/login-page`, `test/filter-general` + Name your branch concisely and descriptively related to the issue/ticket: + Examples: `backend/filter-by-role-api`, `frontend/login-page`, `test/filter-general` + - `git checkout -b branch-name-here` -3. **Implement your changes** +3. **Implement your changes** + - Follow the project structure (e.g. write API functions inside the `src/lib/api/` folder) - Ensure tests pass using `npm run test` (if there are tests created that are related to your function/feature) 4. **Format and lint your code before committing** -Run the following: + Run the following: + - `npm run format` - `npm run lint` 5. **Commit your changes** -Use a meaningful commit message that briefly summarizes the work done: + Use a meaningful commit message that briefly summarizes the work done: + - git add - git commit -m "Add create volunteer API function" 6. **Push your branch to remote** -`git push origin branch-name-here` + `git push origin branch-name-here` + +7. **Create a Pull Request (PR)** -7. **Create a Pull Request (PR)** - Go to the GitHub repo and open a PR from your branch into main - Use a clear title and description that references the ticket - Include screenshots of passed tests (if any test cases) or of the feature working (e.g. working page or logs) @@ -84,16 +97,16 @@ Use a meaningful commit message that briefly summarizes the work done: ## Useful Commands Summary -| Command | Description | -|--------------------------|-------------------------------------------| -| `npm install` | Install project dependencies | -| `npm run dev` | Run development server (localhost:3000) | -| `npm run format` | Format code automatically | -| `npm run lint` | Check for linting errors | -| `npm run test` | Run unit and integration tests | -| `git checkout main` | Switch to main branch | -| `git pull origin main` | Pull latest main branch changes | -| `git checkout -b `| Create and switch to new branch | -| `git add .` | Stage all changed files | -| `git commit -m "msg"` | Commit staged changes with a message | -| `git push origin ` | Push branch to remote repository | +| Command | Description | +| ------------------------ | --------------------------------------- | +| `npm install` | Install project dependencies | +| `npm run dev` | Run development server (localhost:3000) | +| `npm run format` | Format code automatically | +| `npm run lint` | Check for linting errors | +| `npm run test` | Run unit and integration tests | +| `git checkout main` | Switch to main branch | +| `git pull origin main` | Pull latest main branch changes | +| `git checkout -b ` | Create and switch to new branch | +| `git add .` | Stage all changed files | +| `git commit -m "msg"` | Commit staged changes with a message | +| `git push origin ` | Push branch to remote repository | diff --git a/src/app/api/volunteers/[id]/route.ts b/src/app/api/volunteers/[id]/route.ts new file mode 100644 index 0000000..e8135f5 --- /dev/null +++ b/src/app/api/volunteers/[id]/route.ts @@ -0,0 +1,44 @@ +// api route to patch a volunteer by id +import { NextRequest, NextResponse } from "next/server"; +import { + updateVolunteer, + validateVolunteerUpdateBody, +} from "@/lib/api/updateVolunteer"; + +export async function PATCH( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + const volunteerId = Number.parseInt(id, 10); + if (!Number.isSafeInteger(volunteerId) || volunteerId <= 0) { + return NextResponse.json( + { error: "Volunteer id must be a positive integer" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Invalid JSON in request body" }, + { status: 400 } + ); + } + + const { updates, error: validationError } = validateVolunteerUpdateBody(body); + if (validationError || !updates) { + return NextResponse.json({ error: validationError }, { status: 400 }); + } + + const { volunteer, error } = await updateVolunteer(volunteerId, updates); + + if (error) { + const status = error.code === "PGRST116" ? 404 : 500; + return NextResponse.json({ error: error.message }, { status }); + } + + return NextResponse.json({ data: volunteer }, { status: 200 }); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 53e202a..43b83c3 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,3 +1,7 @@ // Add any exports from your API files here export { getExample } from "./getExample"; +export { + updateVolunteer, + validateVolunteerUpdateBody, +} from "./updateVolunteer"; diff --git a/src/lib/api/updateVolunteer.ts b/src/lib/api/updateVolunteer.ts new file mode 100644 index 0000000..c232067 --- /dev/null +++ b/src/lib/api/updateVolunteer.ts @@ -0,0 +1,129 @@ +import type { PostgrestError } from "@supabase/supabase-js"; +import { createClient } from "../client/supabase/server"; +import type { Tables, TablesUpdate } from "../client/supabase/types"; + +export type VolunteerUpdatePayload = Pick< + TablesUpdate<"Volunteers">, + | "name_org" + | "email" + | "phone" + | "pronouns" + | "pseudonym" + | "position" + | "notes" + | "opt_in_communication" +>; + +type VolunteerUpdateResponse = { + volunteer: Tables<"Volunteers"> | null; + error: PostgrestError | null; +}; + +// keep this in sync with allowed patch fields on the volunteers table +const ALLOWED_FIELDS = new Set([ + "name_org", + "email", + "phone", + "pronouns", + "pseudonym", + "position", + "notes", + "opt_in_communication", +]); + +export function validateVolunteerUpdateBody(body: unknown): { + updates?: VolunteerUpdatePayload; + error?: string; +} { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { error: "Request body must be a JSON object" }; + } + + const payload = body as Record; + const unknownKeys = Object.keys(payload).filter( + (key) => !ALLOWED_FIELDS.has(key as keyof VolunteerUpdatePayload) + ); + + if (unknownKeys.length > 0) { + return { + error: `Unknown field(s): ${unknownKeys.join(", ")}`, + }; + } + + // name_org is the only required patchable field; validate it eagerly + const updates: Partial = {}; + if ("name_org" in payload) { + const value = payload.name_org; + if (value === null || value === undefined) { + return { error: "Field name_org must be provided as a non-empty string" }; + } + if (typeof value !== "string") { + return { error: "Field name_org must be a string" }; + } + if (value.trim().length === 0) { + return { error: "Field name_org cannot be empty" }; + } + updates.name_org = value; + } + + // optional string-ish fields can be patched with string or null + const stringFields = [ + "email", + "phone", + "pronouns", + "pseudonym", + "position", + "notes", + ] as const; + type StringFieldKey = (typeof stringFields)[number]; + + for (const key of stringFields) { + if (key in payload) { + const value = payload[key]; + + if (value !== null && value !== undefined && typeof value !== "string") { + return { error: `Field ${key} must be a string or null` }; + } + + updates[key] = value as VolunteerUpdatePayload[StringFieldKey]; + } + } + + if ("opt_in_communication" in payload) { + const value = payload.opt_in_communication; + if (value !== null && value !== undefined && typeof value !== "boolean") { + return { + error: "Field opt_in_communication must be a boolean or null", + }; + } + updates.opt_in_communication = + value as VolunteerUpdatePayload["opt_in_communication"]; + } + + const hasFields = Object.keys(updates).length > 0; + if (!hasFields) { + return { error: "At least one updatable field is required" }; + } + + return { updates: updates as VolunteerUpdatePayload }; +} + +export async function updateVolunteer( + volunteerId: number, + updates: VolunteerUpdatePayload +): Promise { + const client = await createClient(); + + const { data, error } = await client + .from("Volunteers") + .update({ ...updates }) + .eq("id", volunteerId) + .select() + .single(); + + if (error) { + return { volunteer: null, error }; + } + + return { volunteer: data, error: null }; +} diff --git a/tests/lib/api/getExample.test.ts b/tests/lib/api/getExample.test.ts index a17ab37..2348211 100644 --- a/tests/lib/api/getExample.test.ts +++ b/tests/lib/api/getExample.test.ts @@ -12,13 +12,15 @@ vi.mock("@/lib/client/supabase/server", () => ({ })); describe("getExample", () => { + type SupabaseClientType = Awaited>; + const mockedCreateClient = vi.mocked(createClient); const mockSelect = vi.fn(); const mockFrom = vi.fn(() => ({ select: mockSelect })); - const mockClient = { from: mockFrom }; + const mockClient = { from: mockFrom } as unknown as SupabaseClientType; beforeEach(() => { vi.clearAllMocks(); - (createClient as any).mockResolvedValue(mockClient); + mockedCreateClient.mockResolvedValue(mockClient); mockSelect.mockResolvedValue({ data: [{ id: 1, name: "Test Volunteer" }] }); }); From acec1e5d6d603a551e48dcfce97c5ef6ce3ebdd5 Mon Sep 17 00:00:00 2001 From: Dimural Murat Date: Wed, 31 Dec 2025 21:00:49 -0500 Subject: [PATCH 2/4] changes --- src/app/api/volunteers/[id]/route.ts | 44 ----- src/lib/api/index.ts | 5 +- src/lib/api/updateVolunteer.ts | 206 ++++++++++++++++++---- tests/lib/api/updateVolunteer.test.ts | 239 ++++++++++++++++++++++++++ 4 files changed, 415 insertions(+), 79 deletions(-) delete mode 100644 src/app/api/volunteers/[id]/route.ts create mode 100644 tests/lib/api/updateVolunteer.test.ts diff --git a/src/app/api/volunteers/[id]/route.ts b/src/app/api/volunteers/[id]/route.ts deleted file mode 100644 index e8135f5..0000000 --- a/src/app/api/volunteers/[id]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -// api route to patch a volunteer by id -import { NextRequest, NextResponse } from "next/server"; -import { - updateVolunteer, - validateVolunteerUpdateBody, -} from "@/lib/api/updateVolunteer"; - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const volunteerId = Number.parseInt(id, 10); - if (!Number.isSafeInteger(volunteerId) || volunteerId <= 0) { - return NextResponse.json( - { error: "Volunteer id must be a positive integer" }, - { status: 400 } - ); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const { updates, error: validationError } = validateVolunteerUpdateBody(body); - if (validationError || !updates) { - return NextResponse.json({ error: validationError }, { status: 400 }); - } - - const { volunteer, error } = await updateVolunteer(volunteerId, updates); - - if (error) { - const status = error.code === "PGRST116" ? 404 : 500; - return NextResponse.json({ error: error.message }, { status }); - } - - return NextResponse.json({ data: volunteer }, { status: 200 }); -} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 43b83c3..8d521f2 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,7 +1,4 @@ // Add any exports from your API files here export { getExample } from "./getExample"; -export { - updateVolunteer, - validateVolunteerUpdateBody, -} from "./updateVolunteer"; +export { updateVolunteer } from "./updateVolunteer"; diff --git a/src/lib/api/updateVolunteer.ts b/src/lib/api/updateVolunteer.ts index c232067..1cd1aa1 100644 --- a/src/lib/api/updateVolunteer.ts +++ b/src/lib/api/updateVolunteer.ts @@ -1,8 +1,10 @@ -import type { PostgrestError } from "@supabase/supabase-js"; import { createClient } from "../client/supabase/server"; import type { Tables, TablesUpdate } from "../client/supabase/types"; -export type VolunteerUpdatePayload = Pick< +const ROLE_TYPES = ["prior", "current", "future_interest"] as const; +const COHORT_TERMS = ["fall", "summer", "winter", "spring"] as const; + +type VolunteerUpdatePayload = Pick< TablesUpdate<"Volunteers">, | "name_org" | "email" @@ -14,13 +16,15 @@ export type VolunteerUpdatePayload = Pick< | "opt_in_communication" >; -type VolunteerUpdateResponse = { - volunteer: Tables<"Volunteers"> | null; - error: PostgrestError | null; -}; +type RoleInput = { name: string; type: (typeof ROLE_TYPES)[number] }; +type CohortInput = { year: number; term: (typeof COHORT_TERMS)[number] }; + +type UpdateVolunteerResult = + | { status: 200; body: { volunteer: Tables<"Volunteers"> } } + | { status: 400 | 404 | 500; body: { error: string } }; // keep this in sync with allowed patch fields on the volunteers table -const ALLOWED_FIELDS = new Set([ +const ALLOWED_VOLUNTEER_FIELDS = new Set([ "name_org", "email", "phone", @@ -30,9 +34,16 @@ const ALLOWED_FIELDS = new Set([ "notes", "opt_in_communication", ]); +const ALLOWED_TOP_LEVEL_FIELDS = new Set([ + ...ALLOWED_VOLUNTEER_FIELDS, + "role", + "cohort", +]); -export function validateVolunteerUpdateBody(body: unknown): { +function validateVolunteerUpdateBody(body: unknown): { updates?: VolunteerUpdatePayload; + role?: RoleInput; + cohort?: CohortInput; error?: string; } { if (!body || typeof body !== "object" || Array.isArray(body)) { @@ -41,7 +52,7 @@ export function validateVolunteerUpdateBody(body: unknown): { const payload = body as Record; const unknownKeys = Object.keys(payload).filter( - (key) => !ALLOWED_FIELDS.has(key as keyof VolunteerUpdatePayload) + (key) => !ALLOWED_TOP_LEVEL_FIELDS.has(key) ); if (unknownKeys.length > 0) { @@ -53,7 +64,7 @@ export function validateVolunteerUpdateBody(body: unknown): { // name_org is the only required patchable field; validate it eagerly const updates: Partial = {}; if ("name_org" in payload) { - const value = payload.name_org; + const value = payload["name_org"]; if (value === null || value === undefined) { return { error: "Field name_org must be provided as a non-empty string" }; } @@ -75,55 +86,188 @@ export function validateVolunteerUpdateBody(body: unknown): { "position", "notes", ] as const; - type StringFieldKey = (typeof stringFields)[number]; for (const key of stringFields) { if (key in payload) { const value = payload[key]; - if (value !== null && value !== undefined && typeof value !== "string") { + if (value === undefined || value === null) { + updates[key] = null; + } else if (typeof value === "string") { + updates[key] = value; + } else { return { error: `Field ${key} must be a string or null` }; } - - updates[key] = value as VolunteerUpdatePayload[StringFieldKey]; } } if ("opt_in_communication" in payload) { - const value = payload.opt_in_communication; - if (value !== null && value !== undefined && typeof value !== "boolean") { + const value = payload["opt_in_communication"]; + if (value === undefined || value === null) { + updates.opt_in_communication = null; + } else if (typeof value === "boolean") { + updates.opt_in_communication = value; + } else { return { error: "Field opt_in_communication must be a boolean or null", }; } - updates.opt_in_communication = - value as VolunteerUpdatePayload["opt_in_communication"]; } const hasFields = Object.keys(updates).length > 0; - if (!hasFields) { - return { error: "At least one updatable field is required" }; + let role: RoleInput | undefined; + let cohort: CohortInput | undefined; + + if ("role" in payload) { + const r = payload["role"]; + if (!r || typeof r !== "object" || Array.isArray(r)) { + return { error: "Field role must be an object" }; + } + const { name, type } = r as Record; + + if (typeof name !== "string" || name.trim().length === 0) { + return { error: "Field role.name must be a non-empty string" }; + } + if (typeof type !== "string" || !ROLE_TYPES.includes(type as any)) { + return { error: `Field role.type must be one of ${ROLE_TYPES.join(", ")}` }; + } + role = { name, type: type as RoleInput["type"] }; } - return { updates: updates as VolunteerUpdatePayload }; + if ("cohort" in payload) { + const c = payload["cohort"]; + if (!c || typeof c !== "object" || Array.isArray(c)) { + return { error: "Field cohort must be an object" }; + } + const { year, term } = c as Record; + if (!Number.isInteger(year)) { + return { error: "Field cohort.year must be an integer" }; + } + if (typeof term !== "string" || !COHORT_TERMS.includes(term as any)) { + return { error: `Field cohort.term must be one of ${COHORT_TERMS.join(", ")}` }; + } + cohort = { year: year as number, term: term as CohortInput["term"] }; + } + + if (!hasFields && !role && !cohort) { + return { + error: + "At least one updatable field is required (volunteer fields, role, or cohort)", + }; + } + + return { updates: updates as VolunteerUpdatePayload, role, cohort }; } export async function updateVolunteer( - volunteerId: number, - updates: VolunteerUpdatePayload -): Promise { + volunteerId: unknown, + body: unknown +): Promise { + if (!Number.isInteger(volunteerId) || (volunteerId as number) <= 0) { + return { status: 400, body: { error: "Invalid volunteer id" } }; + } + + const validation = validateVolunteerUpdateBody(body); + if (!validation.updates) { + return { + status: 400, + body: { error: validation.error ?? "Invalid volunteer update payload" }, + }; + } + const client = await createClient(); + const timestamp = new Date().toISOString(); - const { data, error } = await client + const { data: volunteer, error: volunteerError } = await client .from("Volunteers") - .update({ ...updates }) - .eq("id", volunteerId) + .update({ ...validation.updates, updated_at: timestamp }) + .eq("id", volunteerId as number) .select() - .single(); + .maybeSingle(); + + if (volunteerError) { + return { status: 500, body: { error: volunteerError.message } }; + } + + if (!volunteer) { + return { status: 404, body: { error: "Volunteer not found" } }; + } + + if (validation.role) { + const { name, type } = validation.role; + const { data: roleRow, error: roleLookupError } = await client + .from("Roles") + .select("id") + .eq("name", name) + .eq("type", type) + .maybeSingle(); - if (error) { - return { volunteer: null, error }; + if (roleLookupError) { + return { status: 500, body: { error: roleLookupError.message } }; + } + + if (!roleRow) { + return { status: 400, body: { error: "Role not found" } }; + } + + const { error: roleDeleteError } = await client + .from("VolunteerRoles") + .delete() + .eq("volunteer_id", volunteerId as number); + + if (roleDeleteError) { + return { status: 500, body: { error: roleDeleteError.message } }; + } + + const { error: roleInsertError } = await client + .from("VolunteerRoles") + .insert({ + volunteer_id: volunteerId as number, + role_id: roleRow.id, + created_at: timestamp, + }); + + if (roleInsertError) { + return { status: 500, body: { error: roleInsertError.message } }; + } + } + + if (validation.cohort) { + const { year, term } = validation.cohort; + const { data: cohortRow, error: cohortLookupError } = await client + .from("Cohorts") + .select("id") + .eq("year", year) + .eq("term", term) + .maybeSingle(); + + if (cohortLookupError) { + return { status: 500, body: { error: cohortLookupError.message } }; + } + + if (!cohortRow) { + return { status: 400, body: { error: "Cohort not found" } }; + } + + const { error: cohortDeleteError } = await client + .from("VolunteerCohorts") + .delete() + .eq("volunteer_id", volunteerId as number); + + if (cohortDeleteError) { + return { status: 500, body: { error: cohortDeleteError.message } }; + } + + const { error: cohortInsertError } = await client.from("VolunteerCohorts").insert({ + volunteer_id: volunteerId as number, + cohort_id: cohortRow.id, + assigned_at: timestamp, + }); + + if (cohortInsertError) { + return { status: 500, body: { error: cohortInsertError.message } }; + } } - return { volunteer: data, error: null }; + return { status: 200, body: { volunteer } }; } diff --git a/tests/lib/api/updateVolunteer.test.ts b/tests/lib/api/updateVolunteer.test.ts new file mode 100644 index 0000000..3fef88f --- /dev/null +++ b/tests/lib/api/updateVolunteer.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { updateVolunteer } from "@/lib/api/updateVolunteer"; +import { createClient } from "@/lib/client/supabase/server"; + +vi.mock("@/lib/client/supabase/server", () => ({ + createClient: vi.fn(), +})); + +const FIXED_TIME = new Date("2024-01-02T03:04:05.000Z"); + +const baseVolunteerRow = { + created_at: "2024-01-01T00:00:00.000Z", + email: null, + id: 1, + name_org: "Existing", + notes: null, + opt_in_communication: null, + phone: null, + position: null, + pronouns: null, + pseudonym: null, + updated_at: "2024-01-01T00:00:00.000Z", +}; + +type MockOptions = { + volunteerMaybeSingleData?: any; + volunteerMaybeSingleError?: any; + roleMaybeSingleData?: any; + roleMaybeSingleError?: any; + cohortMaybeSingleData?: any; + cohortMaybeSingleError?: any; + roleDeleteError?: any; + roleInsertError?: any; + cohortDeleteError?: any; + cohortInsertError?: any; +}; + +function buildMockClient(opts: MockOptions = {}) { + const { + volunteerMaybeSingleData = baseVolunteerRow, + volunteerMaybeSingleError = null, + roleMaybeSingleData = { id: 10 }, + roleMaybeSingleError = null, + cohortMaybeSingleData = { id: 20 }, + cohortMaybeSingleError = null, + roleDeleteError = null, + roleInsertError = null, + cohortDeleteError = null, + cohortInsertError = null, + } = opts; + + const volunteerMaybeSingle = vi.fn().mockResolvedValue({ + data: volunteerMaybeSingleData, + error: volunteerMaybeSingleError, + }); + const volunteerSelect = vi.fn().mockReturnValue({ maybeSingle: volunteerMaybeSingle }); + const volunteerEq = vi.fn().mockReturnValue({ select: volunteerSelect }); + const volunteerUpdate = vi.fn((payload) => ({ eq: volunteerEq, payload })); + + const roleMaybeSingle = vi.fn().mockResolvedValue({ + data: roleMaybeSingleData, + error: roleMaybeSingleError, + }); + const roleEqSecond = vi.fn().mockReturnValue({ maybeSingle: roleMaybeSingle }); + const roleEqFirst = vi.fn().mockReturnValue({ eq: roleEqSecond }); + const roleSelect = vi.fn().mockReturnValue({ eq: roleEqFirst }); + + const roleDeleteEq = vi.fn().mockResolvedValue({ error: roleDeleteError }); + const roleDelete = vi.fn().mockReturnValue({ eq: roleDeleteEq }); + const roleInsert = vi.fn().mockResolvedValue({ error: roleInsertError }); + + const cohortMaybeSingle = vi.fn().mockResolvedValue({ + data: cohortMaybeSingleData, + error: cohortMaybeSingleError, + }); + const cohortEqSecond = vi.fn().mockReturnValue({ maybeSingle: cohortMaybeSingle }); + const cohortEqFirst = vi.fn().mockReturnValue({ eq: cohortEqSecond }); + const cohortSelect = vi.fn().mockReturnValue({ eq: cohortEqFirst }); + + const cohortDeleteEq = vi.fn().mockResolvedValue({ error: cohortDeleteError }); + const cohortDelete = vi.fn().mockReturnValue({ eq: cohortDeleteEq }); + const cohortInsert = vi.fn().mockResolvedValue({ error: cohortInsertError }); + + const client = { + from: vi.fn((table: string) => { + switch (table) { + case "Volunteers": + return { update: volunteerUpdate }; + case "Roles": + return { select: roleSelect }; + case "VolunteerRoles": + return { delete: roleDelete, insert: roleInsert }; + case "Cohorts": + return { select: cohortSelect }; + case "VolunteerCohorts": + return { delete: cohortDelete, insert: cohortInsert }; + default: + throw new Error(`Unexpected table ${table}`); + } + }), + }; + + return { + client, + spies: { + volunteerUpdate, + volunteerEq, + volunteerSelect, + volunteerMaybeSingle, + roleSelect, + roleEqFirst, + roleEqSecond, + roleMaybeSingle, + roleDelete, + roleDeleteEq, + roleInsert, + cohortSelect, + cohortEqFirst, + cohortEqSecond, + cohortMaybeSingle, + cohortDelete, + cohortDeleteEq, + cohortInsert, + }, + captured: { + get volunteerUpdatePayload() { + return volunteerUpdate.mock.calls.at(-1)?.[0]; + }, + get volunteerEqArgs() { + return volunteerEq.mock.calls.at(-1); + }, + get roleInsertPayload() { + return roleInsert.mock.calls.at(-1)?.[0]; + }, + get cohortInsertPayload() { + return cohortInsert.mock.calls.at(-1)?.[0]; + }, + }, + }; +} + +describe("updateVolunteer", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_TIME); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns 400 for invalid volunteer id without touching Supabase", async () => { + const result = await updateVolunteer("bad-id", {}); + + expect(result.status).toBe(400); + expect(result.body.error).toContain("Invalid volunteer id"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for unknown body fields before creating client", async () => { + const result = await updateVolunteer(1, { foo: "bar" }); + + expect(result.status).toBe(400); + expect(result.body.error).toContain("Unknown field"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("updates volunteer fields and stamps updated_at", async () => { + const updatedVolunteer = { + ...baseVolunteerRow, + name_org: "Updated Name", + phone: "123", + updated_at: FIXED_TIME.toISOString(), + }; + const mock = buildMockClient({ volunteerMaybeSingleData: updatedVolunteer }); + vi.mocked(createClient).mockResolvedValue(mock.client as any); + + const result = await updateVolunteer(1, { name_org: "Updated Name", phone: "123" }); + + expect(createClient).toHaveBeenCalled(); + expect(result).toEqual({ status: 200, body: { volunteer: updatedVolunteer } }); + expect(mock.spies.volunteerUpdate).toHaveBeenCalledTimes(1); + expect(mock.captured.volunteerUpdatePayload).toMatchObject({ + name_org: "Updated Name", + phone: "123", + updated_at: FIXED_TIME.toISOString(), + }); + }); + + it("returns 400 when role does not exist", async () => { + const mock = buildMockClient({ roleMaybeSingleData: null }); + vi.mocked(createClient).mockResolvedValue(mock.client as any); + + const result = await updateVolunteer(1, { + role: { name: "Advocate", type: "prior" }, + name_org: "Name", + }); + + expect(result.status).toBe(400); + expect(result.body.error).toBe("Role not found"); + expect(mock.spies.roleDelete).not.toHaveBeenCalled(); + expect(mock.spies.roleInsert).not.toHaveBeenCalled(); + }); + + it("updates role and cohort when both provided", async () => { + const updatedVolunteer = { ...baseVolunteerRow, updated_at: FIXED_TIME.toISOString() }; + const mock = buildMockClient({ + volunteerMaybeSingleData: updatedVolunteer, + roleMaybeSingleData: { id: 5 }, + cohortMaybeSingleData: { id: 7 }, + }); + vi.mocked(createClient).mockResolvedValue(mock.client as any); + + const result = await updateVolunteer(1, { + name_org: "Keep Name", + role: { name: "Advocate", type: "current" }, + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(200); + expect(mock.spies.roleDelete).toHaveBeenCalledTimes(1); + expect(mock.spies.roleInsert).toHaveBeenCalledTimes(1); + expect(mock.captured.roleInsertPayload).toEqual({ + volunteer_id: 1, + role_id: 5, + created_at: FIXED_TIME.toISOString(), + }); + + expect(mock.spies.cohortDelete).toHaveBeenCalledTimes(1); + expect(mock.spies.cohortInsert).toHaveBeenCalledTimes(1); + expect(mock.captured.cohortInsertPayload).toEqual({ + volunteer_id: 1, + cohort_id: 7, + assigned_at: FIXED_TIME.toISOString(), + }); + expect(result.body.volunteer).toEqual(updatedVolunteer); + }); +}); From c5c55d0ff3fdf97291fa7168f69f044c7b0cff04 Mon Sep 17 00:00:00 2001 From: Dimural Murat Date: Wed, 31 Dec 2025 21:10:07 -0500 Subject: [PATCH 3/4] lint errors fixed --- src/lib/api/updateVolunteer.ts | 34 ++++++++---- tests/lib/api/updateVolunteer.test.ts | 77 ++++++++++++++++++++------- 2 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/lib/api/updateVolunteer.ts b/src/lib/api/updateVolunteer.ts index 1cd1aa1..1e19737 100644 --- a/src/lib/api/updateVolunteer.ts +++ b/src/lib/api/updateVolunteer.ts @@ -23,6 +23,13 @@ type UpdateVolunteerResult = | { status: 200; body: { volunteer: Tables<"Volunteers"> } } | { status: 400 | 404 | 500; body: { error: string } }; +type VolunteerValidationResult = { + updates?: Partial; + role?: RoleInput; + cohort?: CohortInput; + error?: string; +}; + // keep this in sync with allowed patch fields on the volunteers table const ALLOWED_VOLUNTEER_FIELDS = new Set([ "name_org", @@ -40,12 +47,7 @@ const ALLOWED_TOP_LEVEL_FIELDS = new Set([ "cohort", ]); -function validateVolunteerUpdateBody(body: unknown): { - updates?: VolunteerUpdatePayload; - role?: RoleInput; - cohort?: CohortInput; - error?: string; -} { +function validateVolunteerUpdateBody(body: unknown): VolunteerValidationResult { if (!body || typeof body !== "object" || Array.isArray(body)) { return { error: "Request body must be a JSON object" }; } @@ -128,7 +130,10 @@ function validateVolunteerUpdateBody(body: unknown): { if (typeof name !== "string" || name.trim().length === 0) { return { error: "Field role.name must be a non-empty string" }; } - if (typeof type !== "string" || !ROLE_TYPES.includes(type as any)) { + if ( + typeof type !== "string" || + !ROLE_TYPES.includes(type as (typeof ROLE_TYPES)[number]) + ) { return { error: `Field role.type must be one of ${ROLE_TYPES.join(", ")}` }; } role = { name, type: type as RoleInput["type"] }; @@ -143,7 +148,10 @@ function validateVolunteerUpdateBody(body: unknown): { if (!Number.isInteger(year)) { return { error: "Field cohort.year must be an integer" }; } - if (typeof term !== "string" || !COHORT_TERMS.includes(term as any)) { + if ( + typeof term !== "string" || + !COHORT_TERMS.includes(term as (typeof COHORT_TERMS)[number]) + ) { return { error: `Field cohort.term must be one of ${COHORT_TERMS.join(", ")}` }; } cohort = { year: year as number, term: term as CohortInput["term"] }; @@ -156,7 +164,15 @@ function validateVolunteerUpdateBody(body: unknown): { }; } - return { updates: updates as VolunteerUpdatePayload, role, cohort }; + const result: VolunteerValidationResult = { updates }; + if (role) { + result.role = role; + } + if (cohort) { + result.cohort = cohort; + } + + return result; } export async function updateVolunteer( diff --git a/tests/lib/api/updateVolunteer.test.ts b/tests/lib/api/updateVolunteer.test.ts index 3fef88f..dbdc040 100644 --- a/tests/lib/api/updateVolunteer.test.ts +++ b/tests/lib/api/updateVolunteer.test.ts @@ -1,6 +1,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { updateVolunteer } from "@/lib/api/updateVolunteer"; import { createClient } from "@/lib/client/supabase/server"; +import type { Tables } from "@/lib/client/supabase/types"; vi.mock("@/lib/client/supabase/server", () => ({ createClient: vi.fn(), @@ -8,7 +9,7 @@ vi.mock("@/lib/client/supabase/server", () => ({ const FIXED_TIME = new Date("2024-01-02T03:04:05.000Z"); -const baseVolunteerRow = { +const baseVolunteerRow: Tables<"Volunteers"> = { created_at: "2024-01-01T00:00:00.000Z", email: null, id: 1, @@ -23,19 +24,51 @@ const baseVolunteerRow = { }; type MockOptions = { - volunteerMaybeSingleData?: any; - volunteerMaybeSingleError?: any; - roleMaybeSingleData?: any; - roleMaybeSingleError?: any; - cohortMaybeSingleData?: any; - cohortMaybeSingleError?: any; - roleDeleteError?: any; - roleInsertError?: any; - cohortDeleteError?: any; - cohortInsertError?: any; + volunteerMaybeSingleData?: Tables<"Volunteers"> | null; + volunteerMaybeSingleError?: unknown; + roleMaybeSingleData?: { id: number } | null; + roleMaybeSingleError?: unknown; + cohortMaybeSingleData?: { id: number } | null; + cohortMaybeSingleError?: unknown; + roleDeleteError?: unknown; + roleInsertError?: unknown; + cohortDeleteError?: unknown; + cohortInsertError?: unknown; }; -function buildMockClient(opts: MockOptions = {}) { +type MockFn = ReturnType; + +type ClientMocks = { + client: { from: MockFn }; + spies: { + volunteerUpdate: MockFn; + volunteerEq: MockFn; + volunteerSelect: MockFn; + volunteerMaybeSingle: MockFn; + roleSelect: MockFn; + roleEqFirst: MockFn; + roleEqSecond: MockFn; + roleMaybeSingle: MockFn; + roleDelete: MockFn; + roleDeleteEq: MockFn; + roleInsert: MockFn; + cohortSelect: MockFn; + cohortEqFirst: MockFn; + cohortEqSecond: MockFn; + cohortMaybeSingle: MockFn; + cohortDelete: MockFn; + cohortDeleteEq: MockFn; + cohortInsert: MockFn; + }; + captured: { + volunteerUpdatePayload: unknown; + volunteerEqArgs: unknown; + roleInsertPayload: unknown; + cohortInsertPayload: unknown; + }; +}; + +function buildMockClient(opts: MockOptions = {}): ClientMocks { const { volunteerMaybeSingleData = baseVolunteerRow, volunteerMaybeSingleError = null, @@ -123,16 +156,16 @@ function buildMockClient(opts: MockOptions = {}) { cohortInsert, }, captured: { - get volunteerUpdatePayload() { + get volunteerUpdatePayload(): unknown { return volunteerUpdate.mock.calls.at(-1)?.[0]; }, - get volunteerEqArgs() { + get volunteerEqArgs(): unknown { return volunteerEq.mock.calls.at(-1); }, - get roleInsertPayload() { + get roleInsertPayload(): unknown { return roleInsert.mock.calls.at(-1)?.[0]; }, - get cohortInsertPayload() { + get cohortInsertPayload(): unknown { return cohortInsert.mock.calls.at(-1)?.[0]; }, }, @@ -174,7 +207,9 @@ describe("updateVolunteer", () => { updated_at: FIXED_TIME.toISOString(), }; const mock = buildMockClient({ volunteerMaybeSingleData: updatedVolunteer }); - vi.mocked(createClient).mockResolvedValue(mock.client as any); + vi.mocked(createClient).mockResolvedValue( + mock.client as unknown as Awaited> + ); const result = await updateVolunteer(1, { name_org: "Updated Name", phone: "123" }); @@ -190,7 +225,9 @@ describe("updateVolunteer", () => { it("returns 400 when role does not exist", async () => { const mock = buildMockClient({ roleMaybeSingleData: null }); - vi.mocked(createClient).mockResolvedValue(mock.client as any); + vi.mocked(createClient).mockResolvedValue( + mock.client as unknown as Awaited> + ); const result = await updateVolunteer(1, { role: { name: "Advocate", type: "prior" }, @@ -210,7 +247,9 @@ describe("updateVolunteer", () => { roleMaybeSingleData: { id: 5 }, cohortMaybeSingleData: { id: 7 }, }); - vi.mocked(createClient).mockResolvedValue(mock.client as any); + vi.mocked(createClient).mockResolvedValue( + mock.client as unknown as Awaited> + ); const result = await updateVolunteer(1, { name_org: "Keep Name", From 65f709ca11337452ec4de7fe91818f7d683502d2 Mon Sep 17 00:00:00 2001 From: Dimural Murat Date: Thu, 1 Jan 2026 19:11:01 -0500 Subject: [PATCH 4/4] tests created --- src/lib/api/updateVolunteer.ts | 20 ++- tests/lib/api/updateVolunteer.test.ts | 188 +++++++++++++++++++++++--- 2 files changed, 180 insertions(+), 28 deletions(-) diff --git a/src/lib/api/updateVolunteer.ts b/src/lib/api/updateVolunteer.ts index 1e19737..0e19efb 100644 --- a/src/lib/api/updateVolunteer.ts +++ b/src/lib/api/updateVolunteer.ts @@ -134,7 +134,9 @@ function validateVolunteerUpdateBody(body: unknown): VolunteerValidationResult { typeof type !== "string" || !ROLE_TYPES.includes(type as (typeof ROLE_TYPES)[number]) ) { - return { error: `Field role.type must be one of ${ROLE_TYPES.join(", ")}` }; + return { + error: `Field role.type must be one of ${ROLE_TYPES.join(", ")}`, + }; } role = { name, type: type as RoleInput["type"] }; } @@ -152,7 +154,9 @@ function validateVolunteerUpdateBody(body: unknown): VolunteerValidationResult { typeof term !== "string" || !COHORT_TERMS.includes(term as (typeof COHORT_TERMS)[number]) ) { - return { error: `Field cohort.term must be one of ${COHORT_TERMS.join(", ")}` }; + return { + error: `Field cohort.term must be one of ${COHORT_TERMS.join(", ")}`, + }; } cohort = { year: year as number, term: term as CohortInput["term"] }; } @@ -274,11 +278,13 @@ export async function updateVolunteer( return { status: 500, body: { error: cohortDeleteError.message } }; } - const { error: cohortInsertError } = await client.from("VolunteerCohorts").insert({ - volunteer_id: volunteerId as number, - cohort_id: cohortRow.id, - assigned_at: timestamp, - }); + const { error: cohortInsertError } = await client + .from("VolunteerCohorts") + .insert({ + volunteer_id: volunteerId as number, + cohort_id: cohortRow.id, + assigned_at: timestamp, + }); if (cohortInsertError) { return { status: 500, body: { error: cohortInsertError.message } }; diff --git a/tests/lib/api/updateVolunteer.test.ts b/tests/lib/api/updateVolunteer.test.ts index dbdc040..367dbad 100644 --- a/tests/lib/api/updateVolunteer.test.ts +++ b/tests/lib/api/updateVolunteer.test.ts @@ -86,7 +86,9 @@ function buildMockClient(opts: MockOptions = {}): ClientMocks { data: volunteerMaybeSingleData, error: volunteerMaybeSingleError, }); - const volunteerSelect = vi.fn().mockReturnValue({ maybeSingle: volunteerMaybeSingle }); + const volunteerSelect = vi + .fn() + .mockReturnValue({ maybeSingle: volunteerMaybeSingle }); const volunteerEq = vi.fn().mockReturnValue({ select: volunteerSelect }); const volunteerUpdate = vi.fn((payload) => ({ eq: volunteerEq, payload })); @@ -94,7 +96,9 @@ function buildMockClient(opts: MockOptions = {}): ClientMocks { data: roleMaybeSingleData, error: roleMaybeSingleError, }); - const roleEqSecond = vi.fn().mockReturnValue({ maybeSingle: roleMaybeSingle }); + const roleEqSecond = vi + .fn() + .mockReturnValue({ maybeSingle: roleMaybeSingle }); const roleEqFirst = vi.fn().mockReturnValue({ eq: roleEqSecond }); const roleSelect = vi.fn().mockReturnValue({ eq: roleEqFirst }); @@ -106,11 +110,15 @@ function buildMockClient(opts: MockOptions = {}): ClientMocks { data: cohortMaybeSingleData, error: cohortMaybeSingleError, }); - const cohortEqSecond = vi.fn().mockReturnValue({ maybeSingle: cohortMaybeSingle }); + const cohortEqSecond = vi + .fn() + .mockReturnValue({ maybeSingle: cohortMaybeSingle }); const cohortEqFirst = vi.fn().mockReturnValue({ eq: cohortEqSecond }); const cohortSelect = vi.fn().mockReturnValue({ eq: cohortEqFirst }); - const cohortDeleteEq = vi.fn().mockResolvedValue({ error: cohortDeleteError }); + const cohortDeleteEq = vi + .fn() + .mockResolvedValue({ error: cohortDeleteError }); const cohortDelete = vi.fn().mockReturnValue({ eq: cohortDeleteEq }); const cohortInsert = vi.fn().mockResolvedValue({ error: cohortInsertError }); @@ -183,11 +191,33 @@ describe("updateVolunteer", () => { vi.useRealTimers(); }); + function resolveClient(mock: ClientMocks): void { + vi.mocked(createClient).mockResolvedValue( + mock.client as unknown as Awaited> + ); + } + + type UpdateResult = Awaited>; + + function getError(result: UpdateResult): string { + if ("error" in result.body) { + return result.body.error; + } + throw new Error("Expected error result but received success"); + } + + function getVolunteer(result: UpdateResult): Tables<"Volunteers"> { + if ("volunteer" in result.body) { + return result.body.volunteer; + } + throw new Error("Expected success result but received error"); + } + it("returns 400 for invalid volunteer id without touching Supabase", async () => { const result = await updateVolunteer("bad-id", {}); expect(result.status).toBe(400); - expect(result.body.error).toContain("Invalid volunteer id"); + expect(getError(result)).toContain("Invalid volunteer id"); expect(createClient).not.toHaveBeenCalled(); }); @@ -195,7 +225,7 @@ describe("updateVolunteer", () => { const result = await updateVolunteer(1, { foo: "bar" }); expect(result.status).toBe(400); - expect(result.body.error).toContain("Unknown field"); + expect(getError(result)).toContain("Unknown field"); expect(createClient).not.toHaveBeenCalled(); }); @@ -206,15 +236,21 @@ describe("updateVolunteer", () => { phone: "123", updated_at: FIXED_TIME.toISOString(), }; - const mock = buildMockClient({ volunteerMaybeSingleData: updatedVolunteer }); - vi.mocked(createClient).mockResolvedValue( - mock.client as unknown as Awaited> - ); + const mock = buildMockClient({ + volunteerMaybeSingleData: updatedVolunteer, + }); + resolveClient(mock); - const result = await updateVolunteer(1, { name_org: "Updated Name", phone: "123" }); + const result = await updateVolunteer(1, { + name_org: "Updated Name", + phone: "123", + }); expect(createClient).toHaveBeenCalled(); - expect(result).toEqual({ status: 200, body: { volunteer: updatedVolunteer } }); + expect(result).toEqual({ + status: 200, + body: { volunteer: updatedVolunteer }, + }); expect(mock.spies.volunteerUpdate).toHaveBeenCalledTimes(1); expect(mock.captured.volunteerUpdatePayload).toMatchObject({ name_org: "Updated Name", @@ -225,9 +261,7 @@ describe("updateVolunteer", () => { it("returns 400 when role does not exist", async () => { const mock = buildMockClient({ roleMaybeSingleData: null }); - vi.mocked(createClient).mockResolvedValue( - mock.client as unknown as Awaited> - ); + resolveClient(mock); const result = await updateVolunteer(1, { role: { name: "Advocate", type: "prior" }, @@ -235,21 +269,22 @@ describe("updateVolunteer", () => { }); expect(result.status).toBe(400); - expect(result.body.error).toBe("Role not found"); + expect(getError(result)).toBe("Role not found"); expect(mock.spies.roleDelete).not.toHaveBeenCalled(); expect(mock.spies.roleInsert).not.toHaveBeenCalled(); }); it("updates role and cohort when both provided", async () => { - const updatedVolunteer = { ...baseVolunteerRow, updated_at: FIXED_TIME.toISOString() }; + const updatedVolunteer = { + ...baseVolunteerRow, + updated_at: FIXED_TIME.toISOString(), + }; const mock = buildMockClient({ volunteerMaybeSingleData: updatedVolunteer, roleMaybeSingleData: { id: 5 }, cohortMaybeSingleData: { id: 7 }, }); - vi.mocked(createClient).mockResolvedValue( - mock.client as unknown as Awaited> - ); + resolveClient(mock); const result = await updateVolunteer(1, { name_org: "Keep Name", @@ -273,6 +308,117 @@ describe("updateVolunteer", () => { cohort_id: 7, assigned_at: FIXED_TIME.toISOString(), }); - expect(result.body.volunteer).toEqual(updatedVolunteer); + expect(getVolunteer(result)).toEqual(updatedVolunteer); + }); + + it("returns 400 when no updatable fields, role, or cohort provided", async () => { + const result = await updateVolunteer(1, {}); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("At least one updatable field"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid role type", async () => { + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "past" }, + }); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("role.type"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 400 for invalid cohort term", async () => { + const result = await updateVolunteer(1, { + cohort: { year: 2024, term: "autumn" }, + }); + + expect(result.status).toBe(400); + expect(getError(result)).toContain("cohort.term"); + expect(createClient).not.toHaveBeenCalled(); + }); + + it("returns 404 when volunteer is not found", async () => { + const mock = buildMockClient({ volunteerMaybeSingleData: null }); + resolveClient(mock); + + const result = await updateVolunteer(1, { name_org: "Name" }); + + expect(result.status).toBe(404); + expect(getError(result)).toBe("Volunteer not found"); + }); + + it("returns 500 when volunteer update fails", async () => { + const mock = buildMockClient({ + volunteerMaybeSingleError: { message: "db error" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { name_org: "Name" }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("db error"); + }); + + it("returns 500 when role delete fails", async () => { + const mock = buildMockClient({ + roleDeleteError: { message: "cannot delete role link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "current" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot delete role link"); + }); + + it("returns 500 when role insert fails", async () => { + const mock = buildMockClient({ + roleInsertError: { message: "cannot insert role link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + role: { name: "Advocate", type: "current" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot insert role link"); + }); + + it("returns 500 when cohort delete fails", async () => { + const mock = buildMockClient({ + cohortDeleteError: { message: "cannot delete cohort link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot delete cohort link"); + }); + + it("returns 500 when cohort insert fails", async () => { + const mock = buildMockClient({ + cohortInsertError: { message: "cannot insert cohort link" }, + }); + resolveClient(mock); + + const result = await updateVolunteer(1, { + name_org: "Name", + cohort: { year: 2024, term: "fall" }, + }); + + expect(result.status).toBe(500); + expect(getError(result)).toBe("cannot insert cohort link"); }); });