/** * @vitest-environment jsdom */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import React from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // Track every method call against the supabase client so we can assert on // the exact filter chain the hook constructs. type Call = { method: string; args: unknown[] }; const calls: Call[] = []; let mockRows: Array> = []; let mockUserId: string | null = "user-1"; function makeQueryBuilder() { const builder: Record = {}; const record = (method: string) => vi.fn((...args: unknown[]) => { calls.push({ method, args }); return builder; }); builder.select = record("select"); builder.eq = record("eq"); builder.in = record("in"); builder.order = record("order"); // The query is awaited; PostgREST builders are thenable. builder.then = (resolve: (v: { data: unknown; error: null }) => unknown) => resolve({ data: mockRows, error: null }); return builder; } const fromMock = vi.fn(() => makeQueryBuilder()); vi.mock("@/lib/supabase/client", () => ({ getSupabaseBrowserClient: () => ({ from: fromMock, }), })); const originalFetch = globalThis.fetch; beforeEach(() => { calls.length = 0; fromMock.mockClear(); mockRows = []; mockUserId = "user-1"; globalThis.fetch = vi.fn(async (input: unknown) => { const url = typeof input === "string" ? input : ((input as { url?: string }).url ?? ""); if (url.includes("/api/auth/me")) { if (!mockUserId) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }); } return new Response(JSON.stringify({ id: mockUserId, isAnonymous: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); } throw new Error(`Unexpected fetch in test: ${url}`); }) as typeof fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); import { useAllUserMovies } from "@/hooks/use-all-user-movies"; function wrapper({ children }: { children: React.ReactNode }) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } }, }); return React.createElement(QueryClientProvider, { client }, children); } describe("useAllUserMovies", () => { it("queries movies via the browser client and never filters by a client-supplied group list", async () => { mockRows = [ { id: "m1", tmdb_id: 100, group_id: "g1", title: "A", watched: false }, { id: "m2", tmdb_id: 200, group_id: "g2", title: "B", watched: false }, ]; const { result } = renderHook(() => useAllUserMovies(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); // Hits the movies table directly. expect(fromMock).toHaveBeenCalledWith("movies"); // Filters on watched=false, but NEVER on group_id with a client list. const eqCalls = calls.filter((c) => c.method === "eq"); expect(eqCalls).toContainEqual({ method: "eq", args: ["watched", false] }); for (const c of eqCalls) { expect(c.args[0]).not.toBe("group_id"); } // Critically: no .in("group_id", [...]) filter (would mean the client // is supplying the group list instead of trusting RLS). const inCalls = calls.filter((c) => c.method === "in"); for (const c of inCalls) { expect(c.args[0]).not.toBe("group_id"); } }); it("returns an empty array when the supabase client returns no rows (simulating RLS rejecting a forged group id)", async () => { // Simulate RLS filtering: the user's auth.uid() doesn't match any // group_members row for the requested data, so the server returns []. mockRows = []; const { result } = renderHook(() => useAllUserMovies(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toEqual([]); }); it("dedupes results by tmdb_id, keeping the first occurrence", async () => { mockRows = [ { id: "m1", tmdb_id: 100, group_id: "g1", title: "First", watched: false }, { id: "m2", tmdb_id: 100, group_id: "g2", title: "Duplicate", watched: false }, { id: "m3", tmdb_id: 200, group_id: "g1", title: "Other", watched: false }, { id: "m4", tmdb_id: 200, group_id: "g3", title: "Another dup", watched: false }, ]; const { result } = renderHook(() => useAllUserMovies(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data).toHaveLength(2); expect(result.current.data?.[0]?.id).toBe("m1"); expect(result.current.data?.[0]?.title).toBe("First"); expect(result.current.data?.[1]?.id).toBe("m3"); }); });