Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { http, HttpResponse } from "msw";
import { Toaster } from "sonner";
import { server } from "@/test-utils/msw-server";
import { CoachingSessionsCard } from "@/components/ui/dashboard/coaching-sessions-card";
import { Some } from "@/types/option";
import { createMockEnrichedSession } from "../../../test-utils";

vi.mock("@/site.config", () => ({
siteConfig: {
env: {
backendServiceURL: "http://localhost:4000",
backendApiVersion: "1.0.0-test",
},
},
}));

vi.mock("@/lib/providers/auth-store-provider", () => ({
useAuthStore: (selector: (state: unknown) => unknown) =>
selector({ userSession: { id: "coach-1", timezone: "UTC" }, isACoach: true }),
}));

vi.mock("@/lib/providers/coaching-sessions-card-filter-store-provider", () => ({
useCoachingSessionsCardFilterStore: (selector: (state: unknown) => unknown) =>
selector({ relationshipFilter: undefined, setRelationshipFilter: vi.fn() }),
}));

vi.mock("@/lib/hooks/use-current-organization", () => ({
useCurrentOrganization: () => ({ currentOrganizationId: "org-1" }),
}));

vi.mock("@/lib/api/coaching-relationships", () => ({
useCoachingRelationshipList: () => ({
relationships: [],
isLoading: false,
isError: undefined,
refresh: vi.fn(),
}),
}));

// One button that hands a fixed series session to the card's resend callback.
vi.mock("@/components/ui/dashboard/session-buckets/buckets-container", () => ({
BucketsContainer: ({
onRequestResendInvite,
}: {
onRequestResendInvite: (s: unknown) => void;
}) => (
<button type="button" onClick={() => onRequestResendInvite(SESSION)}>
trigger resend
</button>
),
}));

const BASE = "http://localhost:4000";
const SESSION = createMockEnrichedSession({
id: "session-7",
coaching_session_series_id: Some("series-9"),
});

// Confirms the resend dialog with the series route answering `seriesResponse`;
// returns every resend path POSTed.
async function confirmResend(seriesResponse: Response) {
const posts: string[] = [];
const record = (response: Response) => ({ request }: { request: Request }) => {
posts.push(new URL(request.url).pathname);
return response;
};
server.use(
http.post(`${BASE}/coaching_session_series/:id/resend_invite`, record(seriesResponse)),
http.post(`${BASE}/coaching_sessions/:id/resend_invite`, record(new HttpResponse(null, { status: 204 })))
);
const user = userEvent.setup();
render(
<>
<CoachingSessionsCard onReschedule={vi.fn()} />
<Toaster />
</>
);
await user.click(screen.getByRole("button", { name: "trigger resend" }));
await screen.findByRole("alertdialog", { name: "Resend series invite?" });
await user.click(screen.getByRole("button", { name: "Resend invite" }));
return { user, posts };
}

describe("CoachingSessionsCard — resend invite", () => {
it("POSTs once to the series route and announces success", async () => {
const { posts } = await confirmResend(new HttpResponse(null, { status: 204 }));
expect(await screen.findByText("Invite sent to you and Alex Chen")).toBeInTheDocument();
expect(posts).toEqual(["/coaching_session_series/series-9/resend_invite"]);
await waitFor(() => expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument());
});

it("shows a retry message on a 502", async () => {
await confirmResend(HttpResponse.json({}, { status: 502 }));
expect(await screen.findByText("Couldn't resend the invite")).toBeInTheDocument();
});

it("offers to resend just this session for a legacy series", async () => {
const { user, posts } = await confirmResend(
HttpResponse.json({ details: { reason: "legacy_series" } }, { status: 409 })
);
await user.click(await screen.findByRole("button", { name: "Resend this session" }));
await waitFor(() =>
expect(posts).toEqual([
"/coaching_session_series/series-9/resend_invite",
"/coaching_sessions/session-7/resend_invite",
])
);
});
});
78 changes: 70 additions & 8 deletions __tests__/components/ui/dashboard/coaching-sessions-row.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { DateTime, Settings } from "ts-luxon";
import { SessionRow } from "@/components/ui/dashboard/coaching-sessions-row";
import { Some } from "@/types/option";
import { Some, type Option } from "@/types/option";
import type { CoachingSessionSeriesSummary } from "@/types/coaching-session";
import { createMockEnrichedSession } from "../../../test-utils";

// `copyCoachingSessionLinkWithToast` writes to the clipboard and fires a
Expand All @@ -21,6 +23,8 @@ function renderRow(opts: {
isPast?: boolean;
/** When set, the session belongs to a series with this id. */
seriesId?: string;
series?: Option<CoachingSessionSeriesSummary>;
onRequestResendInvite?: ReturnType<typeof vi.fn>;
onReschedule?: ReturnType<typeof vi.fn>;
onRequestDelete?: ReturnType<typeof vi.fn>;
onSeriesAction?: ReturnType<typeof vi.fn>;
Expand All @@ -30,11 +34,11 @@ function renderRow(opts: {
const onRequestDelete = opts.onRequestDelete ?? vi.fn();
const onSeriesAction = opts.onSeriesAction ?? vi.fn();
const onSelect = opts.onSelect ?? vi.fn();
const session = createMockEnrichedSession(
opts.seriesId
? { coaching_session_series_id: Some(opts.seriesId) }
: undefined
);
const onRequestResendInvite = opts.onRequestResendInvite ?? vi.fn();
const session = createMockEnrichedSession({
...(opts.seriesId && { coaching_session_series_id: Some(opts.seriesId) }),
...(opts.series && { series: opts.series }),
});
render(
<SessionRow
session={session}
Expand All @@ -45,10 +49,18 @@ function renderRow(opts: {
onSelect={onSelect}
onReschedule={onReschedule}
onRequestDelete={onRequestDelete}
onRequestResendInvite={onRequestResendInvite}
onSeriesAction={onSeriesAction}
/>
);
return { session, onReschedule, onRequestDelete, onSeriesAction, onSelect };
return {
session,
onReschedule,
onRequestDelete,
onRequestResendInvite,
onSeriesAction,
onSelect,
};
}

async function openKebab() {
Expand Down Expand Up @@ -225,3 +237,53 @@ async function openKebabAfterRender() {
});
return openKebab();
}

describe("SessionRow — resend invite", () => {
const originalNow = Settings.now;
const seriesEnding = (iso: string) =>
Some({ id: "series-1", last_session_date: DateTime.fromISO(iso, { zone: "utc" }) });

beforeEach(() => {
vi.clearAllMocks();
Settings.now = () => Date.parse("2026-10-20T12:00:00Z");
});

afterEach(() => {
Settings.now = originalNow;
});

const item = () => screen.queryByTestId("session-row-resend-invite");

it("is hidden from a coachee", async () => {
renderRow({ viewerId: COACHEE_ID });
await openKebab();
expect(item()).not.toBeInTheDocument();
});

it.each([
["an upcoming one-off", { isPast: false }, "Resend invite", true],
["a past one-off", { isPast: true }, "Resend invite", false],
["a series with a session ahead", { isPast: true, seriesId: "series-1", series: seriesEnding("2026-12-01T15:00:00Z") }, "Resend series invite", true],
["a finished series", { isPast: false, seriesId: "series-1", series: seriesEnding("2026-10-01T15:00:00Z") }, "Resend series invite", false],
])("for a coach on %s", async (_, opts, label, enabled) => {
renderRow({ viewerId: COACH_ID, ...opts });
await openKebab();
if (enabled) {
expect(item()).not.toHaveAttribute("aria-disabled");
expect(item()).toHaveTextContent(new RegExp(`^${label}$`));
} else {
expect(item()).toHaveAttribute("aria-disabled", "true");
expect(item()).toHaveTextContent(`${label}No upcoming sessions`);
expect(screen.getByText("No upcoming sessions")).toBeVisible();
}
});

it("hands the session to onRequestResendInvite when clicked", async () => {
const onRequestResendInvite = vi.fn();
const { session } = renderRow({ viewerId: COACH_ID, onRequestResendInvite });
const user = await openKebab();
await user.click(screen.getByTestId("session-row-resend-invite"));
expect(onRequestResendInvite).toHaveBeenCalledTimes(1);
expect(onRequestResendInvite).toHaveBeenCalledWith(session);
});
});
20 changes: 20 additions & 0 deletions __tests__/components/ui/dashboard/resend-invite-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ResendInviteDialog } from "@/components/ui/dashboard/resend-invite-dialog";

describe("ResendInviteDialog", () => {
it("disables both buttons and reads Sending… while sending", () => {
render(
<ResendInviteDialog
open
isSeries
participantName="Alex Chen"
isSending
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
});
});
82 changes: 82 additions & 0 deletions __tests__/e2e/resend-calendar-invite.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { test, expect, type Page } from '@playwright/test'
import {
setupAuthentication,
mockCommonApiRoutes,
MOCK_USER_ID,
SINGLE_RELATIONSHIP,
} from './helpers'

test.use({ timezoneId: 'America/Chicago', locale: 'en-US' })

const RELATIONSHIP = { ...SINGLE_RELATIONSHIP[0], coach_id: MOCK_USER_ID }
const COACHEE = { id: RELATIONSHIP.coachee_id, first_name: 'Jane', last_name: 'Smith', display_name: 'Jane Smith' }

const session = (id: string, date: string, series: { id: string; last_session_date: string } | null) => ({
id,
coaching_relationship_id: RELATIONSHIP.id,
date,
duration_minutes: 60,
title: null,
coaching_session_series_id: series?.id ?? null,
series,
created_at: '2026-10-01T12:00:00Z',
updated_at: '2026-10-01T12:00:00Z',
relationship: RELATIONSHIP,
coachee: COACHEE,
})

const SERIES_SESSION = session('series-session', '2026-10-21T21:00:00', {
id: 'series-1',
last_session_date: '2026-12-30T21:00:00',
})
const PAST_ONE_OFF = session('past-one-off', '2026-10-21T14:00:00', null)

const json = (data: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) })

let resendPosts: string[]

async function openRowMenu(page: Page, sessionId: string) {
const row = page.getByRole('region', { name: 'Today' }).getByTestId(`session-row-${sessionId}`)
await row.hover()
await row.getByRole('button', { name: 'Session actions' }).click()
}

test.describe('Resend calendar invite', () => {
test.beforeEach(async ({ page, context }) => {
resendPosts = []
await page.clock.setFixedTime('2026-10-21T17:00:00Z')
await setupAuthentication(page, context)
await mockCommonApiRoutes(page, { relationships: [RELATIONSHIP] })
await page.route(
(url) => url.pathname === `/users/${MOCK_USER_ID}/coaching_sessions`,
(route) => route.fulfill(json([SERIES_SESSION, PAST_ONE_OFF]))
)
await page.route('**/coaching_sessions/counts**', (route) => route.fulfill(json({ counts: [] })))
await page.route('**/resend_invite', (route) => {
resendPosts.push(`${route.request().method()} ${new URL(route.request().url()).pathname}`)
return route.fulfill({ status: 204 })
})
await page.goto('/dashboard')
})

test('a double-clicked confirm sends one series resend', async ({ page }) => {
await openRowMenu(page, SERIES_SESSION.id)
await page.getByRole('menuitem', { name: 'Resend series invite' }).click()
await page
.getByRole('alertdialog', { name: 'Resend series invite?' })
.getByRole('button', { name: 'Resend invite' })
.dblclick()

await expect(page.getByText('Invite sent to you and Jane Smith')).toBeVisible()
expect(resendPosts).toEqual(['POST /coaching_session_series/series-1/resend_invite'])
})

test('the item is disabled on a past one-off', async ({ page }) => {
await page.getByRole('tab', { name: 'Previous' }).click()
await openRowMenu(page, PAST_ONE_OFF.id)

const item = page.getByTestId('session-row-resend-invite')
await expect(item).toHaveAttribute('aria-disabled', 'true')
await expect(item).toContainText('No upcoming sessions')
})
})
48 changes: 48 additions & 0 deletions __tests__/lib/api/resend-invite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CoachingSessionSeriesApi } from "@/lib/api/coaching-session-series";
import { CoachingSessionApi } from "@/lib/api/coaching-sessions";
import { sessionGuard } from "@/lib/auth/session-guard";
import { Some } from "@/types/option";
import { ResendInviteRefusal } from "@/types/resend-invite";

vi.mock("@/lib/auth/session-guard", () => ({
sessionGuard: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
}));

vi.mock("@/site.config", () => ({
siteConfig: { env: { backendServiceURL: "http://localhost:3000" } },
}));

const axiosError = (response?: { status: number; data: unknown }) =>
Object.assign(new Error("Request failed"), { isAxiosError: true, response });

const refusal = (reason: string) =>
axiosError({ status: 409, data: { status_code: 409, details: { reason } } });

describe("resendInvite", () => {
beforeEach(() => vi.clearAllMocks());

it.each([
["series", () => CoachingSessionSeriesApi.resendInvite("ser-1"), "/coaching_session_series/ser-1/resend_invite"],
["session", () => CoachingSessionApi.resendInvite("s-1"), "/coaching_sessions/s-1/resend_invite"],
])("POSTs a %s resend to its route with no body", async (_, call, path) => {
vi.mocked(sessionGuard.post).mockResolvedValue({ status: 204, data: "" } as never);
const result = await call();
expect(sessionGuard.post).toHaveBeenCalledWith(`http://localhost:3000${path}`);
expect(result.isOk()).toBe(true);
});

it.each([
["409 legacy_series", refusal("legacy_series"), { kind: "refused", reason: ResendInviteRefusal.LegacySeries }],
["409 no_upcoming_sessions", refusal("no_upcoming_sessions"), { kind: "refused", reason: ResendInviteRefusal.NoUpcomingSessions }],
["409 series_member", refusal("series_member"), { kind: "refused", reason: ResendInviteRefusal.SeriesMember }],
["409 without a reason", axiosError({ status: 409, data: { status_code: 409 } }), { kind: "unexpected", status: Some(409) }],
["502", axiosError({ status: 502, data: {} }), { kind: "not_delivered" }],
["403", axiosError({ status: 403, data: {} }), { kind: "unexpected", status: Some(403) }],
["no response", axiosError(), { kind: "network" }],
])("maps %s", async (_, error, expected) => {
vi.mocked(sessionGuard.post).mockRejectedValue(error);
const result = await CoachingSessionApi.resendInvite("s-1");
expect(result._unsafeUnwrapErr()).toEqual(expected);
});
});
Loading
Loading