| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135 |
- /**
- * @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<Record<string, unknown>> = [];
- let mockUserId: string | null = "user-1";
- function makeQueryBuilder() {
- const builder: Record<string, unknown> = {};
- 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");
- });
- });
|