|
@@ -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);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|