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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * @vitest-environment jsdom
  3. */
  4. import { describe, it, expect, vi, beforeEach } 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. auth: {
  35. getUser: async () => ({ data: { user: mockUserId ? { id: mockUserId } : null } }),
  36. onAuthStateChange: () => ({
  37. data: { subscription: { unsubscribe: () => {} } },
  38. }),
  39. },
  40. }),
  41. }));
  42. import { useAllUserMovies } from "@/hooks/use-all-user-movies";
  43. function wrapper({ children }: { children: React.ReactNode }) {
  44. const client = new QueryClient({
  45. defaultOptions: { queries: { retry: false, gcTime: 0 } },
  46. });
  47. return React.createElement(QueryClientProvider, { client }, children);
  48. }
  49. beforeEach(() => {
  50. calls.length = 0;
  51. fromMock.mockClear();
  52. mockRows = [];
  53. mockUserId = "user-1";
  54. });
  55. describe("useAllUserMovies", () => {
  56. it("queries movies via the browser client and never filters by a client-supplied group list", async () => {
  57. mockRows = [
  58. { id: "m1", tmdb_id: 100, group_id: "g1", title: "A", watched: false },
  59. { id: "m2", tmdb_id: 200, group_id: "g2", title: "B", watched: false },
  60. ];
  61. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  62. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  63. // Hits the movies table directly.
  64. expect(fromMock).toHaveBeenCalledWith("movies");
  65. // Filters on watched=false, but NEVER on group_id with a client list.
  66. const eqCalls = calls.filter((c) => c.method === "eq");
  67. expect(eqCalls).toContainEqual({ method: "eq", args: ["watched", false] });
  68. for (const c of eqCalls) {
  69. expect(c.args[0]).not.toBe("group_id");
  70. }
  71. // Critically: no .in("group_id", [...]) filter (would mean the client
  72. // is supplying the group list instead of trusting RLS).
  73. const inCalls = calls.filter((c) => c.method === "in");
  74. for (const c of inCalls) {
  75. expect(c.args[0]).not.toBe("group_id");
  76. }
  77. });
  78. it("returns an empty array when the supabase client returns no rows (simulating RLS rejecting a forged group id)", async () => {
  79. // Simulate RLS filtering: the user's auth.uid() doesn't match any
  80. // group_members row for the requested data, so the server returns [].
  81. mockRows = [];
  82. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  83. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  84. expect(result.current.data).toEqual([]);
  85. });
  86. it("dedupes results by tmdb_id, keeping the first occurrence", async () => {
  87. mockRows = [
  88. { id: "m1", tmdb_id: 100, group_id: "g1", title: "First", watched: false },
  89. { id: "m2", tmdb_id: 100, group_id: "g2", title: "Duplicate", watched: false },
  90. { id: "m3", tmdb_id: 200, group_id: "g1", title: "Other", watched: false },
  91. { id: "m4", tmdb_id: 200, group_id: "g3", title: "Another dup", watched: false },
  92. ];
  93. const { result } = renderHook(() => useAllUserMovies(), { wrapper });
  94. await waitFor(() => expect(result.current.isSuccess).toBe(true));
  95. expect(result.current.data).toHaveLength(2);
  96. expect(result.current.data?.[0]?.id).toBe("m1");
  97. expect(result.current.data?.[0]?.title).toBe("First");
  98. expect(result.current.data?.[1]?.id).toBe("m3");
  99. });
  100. });