Переглянути джерело

Merge branch 'worktree-agent-aba8e3d3'

User 2 місяців тому
батько
коміт
7e4f51dff0

+ 142 - 0
src/__tests__/hooks/use-realtime-movies.test.ts

@@ -0,0 +1,142 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, waitFor } from "@testing-library/react";
+import React from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+let lastOnPayload:
+  | ((payload: Record<string, unknown>) => void)
+  | null = null;
+
+vi.mock("@/hooks/use-realtime-channel", () => ({
+  useRealtimeChannel: (opts: {
+    config: { onPayload: (payload: Record<string, unknown>) => void };
+  }) => {
+    lastOnPayload = opts.config.onPayload;
+    return { status: "connected" };
+  },
+}));
+
+const TEST_USER_ID = "user-abc-123";
+
+vi.mock("@/lib/supabase/client", () => ({
+  getSupabaseBrowserClient: () => ({
+    auth: {
+      getUser: vi.fn().mockResolvedValue({
+        data: { user: { id: TEST_USER_ID } },
+        error: null,
+      }),
+    },
+  }),
+}));
+
+import { useRealtimeMovies } from "@/hooks/use-realtime-movies";
+
+const GROUP_ID = "group-xyz-789";
+
+interface MockMovieRow {
+  id: string;
+  group_id: string;
+  tmdb_id: number;
+  title: string;
+}
+
+function makeMovie(id: string): MockMovieRow {
+  return { id, group_id: GROUP_ID, tmdb_id: 1, title: `Movie ${id}` };
+}
+
+function makeWrapper() {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  // Seed the cache so setQueryData callbacks see existing data.
+  queryClient.setQueryData(["group-movies", GROUP_ID], {
+    pages: [[makeMovie("seed-1")]],
+    pageParams: [0],
+  });
+  const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+  const wrapper = ({ children }: { children: React.ReactNode }) =>
+    React.createElement(QueryClientProvider, { client: queryClient }, children);
+  return { wrapper, queryClient, invalidateSpy };
+}
+
+describe("useRealtimeMovies — dual invalidation of all-user-movies", () => {
+  beforeEach(() => {
+    lastOnPayload = null;
+  });
+
+  async function renderAndWaitForUser() {
+    const { wrapper, queryClient, invalidateSpy } = makeWrapper();
+    const hook = renderHook(() => useRealtimeMovies(GROUP_ID), { wrapper });
+    await waitFor(() => {
+      expect(lastOnPayload).not.toBeNull();
+    });
+    // Flush microtasks so the auth.getUser() promise resolves and the
+    // userId ref is populated before the test fires a realtime payload.
+    await Promise.resolve();
+    await Promise.resolve();
+    return { hook, queryClient, invalidateSpy };
+  }
+
+  function allUserMoviesInvalidated(spy: { mock: { calls: unknown[][] } }): boolean {
+    return spy.mock.calls.some((call) => {
+      const arg = call[0] as { queryKey?: unknown[] } | undefined;
+      const key = arg?.queryKey;
+      return (
+        Array.isArray(key) &&
+        key[0] === "all-user-movies" &&
+        key[1] === TEST_USER_ID
+      );
+    });
+  }
+
+  it("invalidates ['all-user-movies', userId] on INSERT realtime event", async () => {
+    const { invalidateSpy } = await renderAndWaitForUser();
+
+    lastOnPayload!({
+      eventType: "INSERT",
+      new: makeMovie("new-1"),
+      old: {},
+    });
+
+    expect(allUserMoviesInvalidated(invalidateSpy)).toBe(true);
+  });
+
+  it("invalidates ['all-user-movies', userId] on UPDATE realtime event", async () => {
+    const { invalidateSpy } = await renderAndWaitForUser();
+
+    lastOnPayload!({
+      eventType: "UPDATE",
+      new: { ...makeMovie("seed-1"), title: "Updated" },
+      old: makeMovie("seed-1"),
+    });
+
+    expect(allUserMoviesInvalidated(invalidateSpy)).toBe(true);
+  });
+
+  it("invalidates ['all-user-movies', userId] on DELETE realtime event", async () => {
+    const { invalidateSpy } = await renderAndWaitForUser();
+
+    lastOnPayload!({
+      eventType: "DELETE",
+      new: {},
+      old: makeMovie("seed-1"),
+    });
+
+    expect(allUserMoviesInvalidated(invalidateSpy)).toBe(true);
+  });
+
+  it("still updates the per-group cache on INSERT (does not regress existing behavior)", async () => {
+    const { queryClient } = await renderAndWaitForUser();
+
+    lastOnPayload!({
+      eventType: "INSERT",
+      new: makeMovie("new-2"),
+      old: {},
+    });
+
+    const cached = queryClient.getQueryData<{
+      pages: MockMovieRow[][];
+    }>(["group-movies", GROUP_ID]);
+    expect(cached?.pages[0].some((m) => m.id === "new-2")).toBe(true);
+  });
+});

+ 6 - 6
src/__tests__/movies/realtime-cache.test.ts

@@ -9,20 +9,20 @@ describe("Realtime movies cache update logic", () => {
 
   it("handles INSERT events with duplicate guard", () => {
     expect(src).toContain("INSERT");
-    // Verify duplicate check exists to prevent optimistic update collisions
-    expect(src).toMatch(/old\.some\(.*\.id\s*===\s*newMovie\.id/);
+    // Verify duplicate check exists across pages to prevent optimistic update collisions
+    expect(src).toMatch(/pages\.some.*page.*some.*\.id\s*===\s*newMovie\.id/);
   });
 
   it("handles UPDATE events with map replacement", () => {
     expect(src).toContain("UPDATE");
-    // Verify map-based replacement pattern
-    expect(src).toMatch(/old\.map\(/);
+    // Verify map-based replacement across pages
+    expect(src).toMatch(/pages\.map\(/);
   });
 
   it("handles DELETE events with filter removal", () => {
     expect(src).toContain("DELETE");
-    // Verify filter-based removal pattern
-    expect(src).toMatch(/old\.filter\(/);
+    // Verify filter-based removal across pages
+    expect(src).toMatch(/page\.filter\(/);
   });
 
   it("guards against null old data in setQueryData callbacks", () => {

+ 9 - 1
src/hooks/use-add-movie.ts

@@ -1,4 +1,5 @@
 import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 import type { Database } from "@/types/database";
 
 type Movie = Database["public"]["Tables"]["movies"]["Row"];
@@ -27,8 +28,15 @@ export function useAddMovie() {
       const data = await res.json();
       return data.movie;
     },
-    onSuccess: (_data, variables) => {
+    onSuccess: async (_data, variables) => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", variables.group_id] });
+      const supabase = getSupabaseBrowserClient();
+      const {
+        data: { user },
+      } = await supabase.auth.getUser();
+      if (user) {
+        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      }
     },
   });
 }

+ 9 - 1
src/hooks/use-delete-movie.ts

@@ -1,6 +1,7 @@
 "use client";
 
 import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 
 async function deleteMovie(movieId: string) {
   const res = await fetch(`/api/movies/${movieId}`, {
@@ -20,8 +21,15 @@ export function useDeleteMovie(groupId: string) {
 
   return useMutation({
     mutationFn: deleteMovie,
-    onSuccess: () => {
+    onSuccess: async () => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", groupId] });
+      const supabase = getSupabaseBrowserClient();
+      const {
+        data: { user },
+      } = await supabase.auth.getUser();
+      if (user) {
+        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      }
     },
   });
 }

+ 29 - 1
src/hooks/use-realtime-movies.ts

@@ -1,11 +1,12 @@
 "use client";
 
-import { useCallback } from "react";
+import { useCallback, useEffect, useRef } from "react";
 import type { RealtimePostgresChangesPayload } from "@supabase/supabase-js";
 import { useQueryClient, type InfiniteData } from "@tanstack/react-query";
 import type { Database } from "@/types/database";
 import { useRealtimeChannel } from "./use-realtime-channel";
 import { buildEqFilter } from "@/lib/realtime/subscription-manager";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 
 type MovieRow = Database["public"]["Tables"]["movies"]["Row"];
 
@@ -20,6 +21,21 @@ function moviesQueryKey(groupId: string): readonly [string, string] {
  */
 export function useRealtimeMovies(groupId: string | null) {
   const queryClient = useQueryClient();
+  const userIdRef = useRef<string | null>(null);
+
+  // Resolve current user id once so realtime events can dual-invalidate
+  // the cross-list ["all-user-movies", userId] query key alongside the
+  // per-group ["group-movies", groupId] cache update.
+  useEffect(() => {
+    let cancelled = false;
+    const supabase = getSupabaseBrowserClient();
+    void supabase.auth.getUser().then(({ data }) => {
+      if (!cancelled) userIdRef.current = data.user?.id ?? null;
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
 
   const handlePayload = useCallback(
     (payload: RealtimePostgresChangesPayload<MovieRow>) => {
@@ -40,6 +56,10 @@ export function useRealtimeMovies(groupId: string | null) {
             pageParams: old.pageParams,
           };
         });
+        const userId = userIdRef.current;
+        if (userId) {
+          void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
+        }
       } else if (payload.eventType === "UPDATE") {
         const updated = payload.new;
         queryClient.setQueryData<InfiniteData<MovieRow[]>>(key, (old) => {
@@ -51,6 +71,10 @@ export function useRealtimeMovies(groupId: string | null) {
             pageParams: old.pageParams,
           };
         });
+        const userId = userIdRef.current;
+        if (userId) {
+          void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
+        }
       } else if (payload.eventType === "DELETE") {
         const deleted = payload.old;
         if (deleted && "id" in deleted) {
@@ -63,6 +87,10 @@ export function useRealtimeMovies(groupId: string | null) {
               pageParams: old.pageParams,
             };
           });
+          const userId = userIdRef.current;
+          if (userId) {
+            void queryClient.invalidateQueries({ queryKey: ["all-user-movies", userId] });
+          }
         }
       }
     },

+ 9 - 1
src/hooks/use-toggle-watched.ts

@@ -1,6 +1,7 @@
 "use client";
 
 import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
 
 interface ToggleWatchedVars {
   movieId: string;
@@ -27,8 +28,15 @@ export function useToggleWatched(groupId: string) {
 
   return useMutation({
     mutationFn: toggleWatched,
-    onSuccess: () => {
+    onSuccess: async () => {
       void queryClient.invalidateQueries({ queryKey: ["group-movies", groupId] });
+      const supabase = getSupabaseBrowserClient();
+      const {
+        data: { user },
+      } = await supabase.auth.getUser();
+      if (user) {
+        void queryClient.invalidateQueries({ queryKey: ["all-user-movies", user.id] });
+      }
     },
   });
 }