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
57 changes: 57 additions & 0 deletions src/api/invite-validation/useAcceptInviteMutation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { groupsKeys } from "@/api/groups/types";
import type { InviteUsageResult } from "@/types/invites";

interface AcceptInviteParams {
token: string;
userId: string;
}

export interface AcceptInviteResult extends InviteUsageResult {
alreadyMember: boolean;
}

async function acceptInvite({
token,
userId,
}: AcceptInviteParams): Promise<AcceptInviteResult> {
const { data, error } = await supabase.rpc("use_invite_token", {
token,
user_id: userId,
});

if (error) {
console.error("Error using invite:", error);
throw new Error("Failed to join group");
}
Comment thread
chiptus marked this conversation as resolved.

const result = data?.[0];
if (!result) {
throw new Error("Failed to join group");
}

if (result.success) {
return { ...result, alreadyMember: false };
}

// Already-in-group is the only failure where the RPC returns a group_id
if (result.group_id) {
return { ...result, alreadyMember: true };
}

throw new Error(result.message);
}

export function useAcceptInviteMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: acceptInvite,
onSuccess: (result) => {
if (!result.alreadyMember) {
queryClient.invalidateQueries({ queryKey: groupsKeys.all });
}
},
});
}
54 changes: 1 addition & 53 deletions src/api/invite-validation/useInviteValidationQuery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { queryOptions, useQuery, useMutation } from "@tanstack/react-query";
import { queryOptions, useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/components/ui/use-toast";
import type { InviteValidation } from "@/types/invites";
import { inviteValidationKeys } from "./types";

Expand Down Expand Up @@ -39,54 +38,3 @@ export function useInviteValidationQuery(token: string | null) {
enabled: !!token,
});
}

export function useInviteMutation() {
const { toast } = useToast();

return useMutation({
mutationFn: async ({
token,
userId,
}: {
token: string;
userId: string;
}) => {
const { data, error } = await supabase.rpc("use_invite_token", {
token,
user_id: userId,
});

if (error) {
console.error("Error using invite:", error);
throw new Error("Failed to join group");
}

if (data && data.length > 0) {
const result = data[0];
if (result.success) {
return result;
} else {
throw new Error(result.message);
}
}

throw new Error("Failed to join group");
},
onSuccess: () => {
// We'll need to get the group name from somewhere - let's make this flexible
toast({
title: "Success",
description: "Successfully joined the group!",
});
return true;
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
return false;
},
});
}
8 changes: 4 additions & 4 deletions src/components/invite/InviteLandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import type { InviteValidation } from "@/types/invites";

interface InviteLandingPageProps {
inviteValidation: InviteValidation;
onSignupSuccess: () => void;
inviteToken: string;
}

export function InviteLandingPage({
inviteValidation,
onSignupSuccess,
inviteToken,
}: InviteLandingPageProps) {
const [showAuthDialog, setShowAuthDialog] = useState(false);

Expand Down Expand Up @@ -73,8 +73,8 @@ export function InviteLandingPage({
<AuthDialog
open={showAuthDialog}
onOpenChange={setShowAuthDialog}
onSuccess={onSignupSuccess}
inviteToken={inviteValidation.invite_id}
onSuccess={() => setShowAuthDialog(false)}
inviteToken={inviteToken}
groupName={inviteValidation.group_name}
/>
</div>
Expand Down
240 changes: 240 additions & 0 deletions src/components/invite/useInviteFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { User } from "@supabase/supabase-js";
import { useInviteFlow } from "./useInviteFlow";
import { supabase } from "@/integrations/supabase/client";
import type { InviteValidation } from "@/types/invites";

const { navigateMock, toastMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
toastMock: vi.fn(),
}));

vi.mock("@/integrations/supabase/client", () => ({
supabase: { rpc: vi.fn() },
}));

vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigateMock,
}));

vi.mock("@/components/ui/use-toast", () => ({
useToast: () => ({ toast: toastMock }),
}));

const rpcMock = vi.mocked(supabase.rpc);

const user = { id: "user-1" } as User;

const validInvite: InviteValidation = {
invite_id: "invite-row-id",
group_id: "group-1",
group_name: "Festival Crew",
is_valid: true,
reason: "valid",
};

const joinedResult = {
success: true,
message: "Successfully joined group",
group_id: "group-1",
};

function mockRpc(
validation: InviteValidation | null,
useResult: { success: boolean; message: string; group_id: string | null },
) {
rpcMock.mockImplementation((async (fn: string) => {
if (fn === "validate_invite_token") {
return { data: validation ? [validation] : [], error: null };
}
return { data: [useResult], error: null };
}) as never);
}

function useInviteTokenCalls() {
return rpcMock.mock.calls.filter(([fn]) => fn === "use_invite_token");
}

function renderFlow(initialProps: {
token: string | undefined;
user: User | null;
}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");

const view = renderHook(
(props: { token: string | undefined; user: User | null }) =>
useInviteFlow(props.token, props.user),
{
initialProps,
wrapper: function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
},
},
);

return { ...view, invalidateSpy };
}

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

it("validates the token then accepts the invite when a user is present", async () => {
mockRpc(validInvite, joinedResult);

const { result, invalidateSpy } = renderFlow({ token: "token-1", user });

await waitFor(() =>
expect(rpcMock).toHaveBeenCalledWith("validate_invite_token", {
token: "token-1",
}),
);
await waitFor(() =>
expect(rpcMock).toHaveBeenCalledWith("use_invite_token", {
token: "token-1",
user_id: "user-1",
}),
);

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Success",
description: "Welcome to Festival Crew!",
}),
);

expect(result.current.hasValidInvite).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["groups"] });
expect(navigateMock).toHaveBeenCalledWith({
to: ".",
search: expect.any(Function),
replace: true,
});

const searchUpdater = navigateMock.mock.calls[0][0].search;
expect(searchUpdater({ invite: "token-1", day: "friday" })).toEqual({
invite: undefined,
day: "friday",
});
});

it("waits for the user to sign in before accepting", async () => {
mockRpc(validInvite, joinedResult);

const { result, rerender } = renderFlow({ token: "token-1", user: null });

await waitFor(() => expect(result.current.hasValidInvite).toBe(true));
expect(useInviteTokenCalls()).toHaveLength(0);

rerender({ token: "token-1", user });

await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1));
});

it("shows the expired message and never consumes an invalid invite", async () => {
mockRpc(
{ ...validInvite, is_valid: false, reason: "invite_expired" },
joinedResult,
);

const { result } = renderFlow({ token: "token-1", user });

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Invalid Invite",
description: "This invite link has expired",
variant: "destructive",
}),
);

expect(result.current.hasValidInvite).toBe(false);
expect(useInviteTokenCalls()).toHaveLength(0);
});

it("surfaces an already-member reuse cleanly without an error", async () => {
mockRpc(validInvite, {
success: false,
message: "User already in group",
group_id: "group-1",
});

const { invalidateSpy } = renderFlow({ token: "token-1", user });

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Already a member",
description: "You're already a member of Festival Crew.",
}),
);

expect(toastMock).not.toHaveBeenCalledWith(
expect.objectContaining({ variant: "destructive" }),
);
expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["groups"] });
expect(navigateMock).toHaveBeenCalled();
});

it("shows an error toast and keeps the invite param when acceptance fails", async () => {
mockRpc(validInvite, {
success: false,
message: "Invalid invite token",
group_id: null,
});

renderFlow({ token: "token-1", user });

await waitFor(() =>
expect(toastMock).toHaveBeenCalledWith({
title: "Couldn't join group",
description: "Invalid invite token",
variant: "destructive",
}),
);

expect(navigateMock).not.toHaveBeenCalled();
});

it("allows a retry after a failed attempt", async () => {
mockRpc(validInvite, {
success: false,
message: "Invalid invite token",
group_id: null,
});

const { rerender } = renderFlow({ token: "token-1", user });

await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1));

mockRpc(validInvite, joinedResult);
rerender({ token: "token-1", user: { ...user } });

await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(2));
});

it("only accepts once per token across re-renders", async () => {
mockRpc(validInvite, joinedResult);

const { rerender } = renderFlow({ token: "token-1", user });

await waitFor(() => expect(useInviteTokenCalls()).toHaveLength(1));

rerender({ token: "token-1", user });

await Promise.resolve();
expect(useInviteTokenCalls()).toHaveLength(1);
});
});
Loading