use-all-user-movies.test.ts 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
  1. /**
  2. * @vitest-environment jsdom
  3. */
  4. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
  5. import { renderHook, waitFor } from "@testing-library/react";
  6. import React from "react";
  7. import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
  8. // Track every method call against the supabase client so we can assert on
  9. // the exact filter chain the hook constructs.
  10. type Call = { method: string; args: unknown[] };
  11. const calls: Call[] = [];
  12. let mockRows: Array<Record<string, unknown>> = [];
  13. let mockUserId: string | null = "user-1";
  14. function makeQueryBuilder() {
  15. const builder: Record<string, unknown> = {};
  16. const record = (method: string) =>
  17. vi.fn((...args: unknown[]) => {
  18. calls.push({ method, args });
  19. return builder;
  20. });
  21. builder.select = record("select");
  22. builder.eq = record("eq");
  23. builder.in = record("in");
  24. builder.order = record("order");
  25. // The query is awaited; PostgREST builders are thenable.
  26. builder.then = (resolve: (v: { data: unknown; error: null }) => unknown) =>
  27. resolve({ data: mockRows, error: null });
  28. return builder;
  29. }
  30. const fromMock = vi.fn(() => makeQueryBuilder());
  31. vi.mock("@/lib/supabase/client", () => ({
  32. getSupabaseBrowserClient: () => ({
  33. from: fromMock,
  34. }),
  35. }));
  36. const originalFetch = globalThis.fetch;
  37. beforeEach(() => {
  38. calls.length = 0;
  39. fromMock.mockClear();
  40. mockRows = [];
  41. mockUserId = "user-1";
  42. globalThis.fetch = vi.fn(async (input: unknown) => {
  43. const url = typeof input === "string" ? input : ((input as { url?: string }).url ?? "");
  44. if (url.includes("/api/auth/me")) {
  45. if (!mockUserId) {
  46. return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
  47. }
  48. return new Response(JSON.stringify({ id: mockUserId, isAnonymous: true }), {
  49. status: 200,
  50. headers: { "Content-Type": "application/json" },
  51. });
  52. }
  53. throw new Error(`Unexpected fetch in test: ${url}`);
  54. }) as typeof fetch;
  55. });
  56. afterEach(() => {
  57. globalThis.fetch = originalFetch;
  58. });
  59. import { useAllUserMovies } from "@/hooks/use-all-user-movies";
  60. function wrapper({ children }: { children: React.ReactNode }) {
  61. const client = new QueryClient({
  62. defaultOptions: { queries: { retry: false, gcTime: 0 } },
  63. });
  64. return React.createElement(QueryClientProvider, { client }, children);
  65. }
  66. describe("useAllUserMovies", () => {
  67. it("queries movies via the browser client and never filters by a client-supplied group list", async () => {
  68. mockRows = [
  69. { id: "m1", tmdb_id: 100, group_id: "g1", title: "A", watched: false },
  70. { id: "m2", tmdb_id: 200, group_id: "g2", title: "B", watched: false },
  71. ];
  72. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  73. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  74. // Hits the movies table directly.
  75. expect(fromMock).toHaveBeenCalledWith("movies");
  76. // Filters on watched=false, but NEVER on group_id with a client list.
  77. const eqCalls = calls.filter((c) => c.method === "eq");
  78. expect(eqCalls).toContainEqual({ method: "eq", args: ["watched", false] });
  79. for (const c of eqCalls) {
  80. expect(c.args[0]).not.toBe("group_id");
  81. }
  82. // Critically: no .in("group_id", [...]) filter (would mean the client
  83. // is supplying the group list instead of trusting RLS).
  84. const inCalls = calls.filter((c) => c.method === "in");
  85. for (const c of inCalls) {
  86. expect(c.args[0]).not.toBe("group_id");
  87. }
  88. });
  89. it("returns an empty array when the supabase client returns no rows (simulating RLS rejecting a forged group id)", async () => {
  90. // Simulate RLS filtering: the user's auth.uid() doesn't match any
  91. // group_members row for the requested data, so the server returns [].
  92. mockRows = [];
  93. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  94. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  95. expect(result.current.data).toEqual([]);
  96. });
  97. it("dedupes results by tmdb_id, keeping the first occurrence", async () => {
  98. mockRows = [
  99. { id: "m1", tmdb_id: 100, group_id: "g1", title: "First", watched: false },
  100. { id: "m2", tmdb_id: 100, group_id: "g2", title: "Duplicate", watched: false },
  101. { id: "m3", tmdb_id: 200, group_id: "g1", title: "Other", watched: false },
  102. { id: "m4", tmdb_id: 200, group_id: "g3", title: "Another dup", watched: false },
  103. ];
  104. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  105. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  106. expect(result.current.data).toHaveLength(2);
  107. expect(result.current.data?.[0]?.id).toBe("m1");
  108. expect(result.current.data?.[0]?.title).toBe("First");
  109. expect(result.current.data?.[1]?.id).toBe("m3");
  110. });
  111. });