From 53a9464eb42ff1690845a066d578be755345cda7 Mon Sep 17 00:00:00 2001 From: 0xDeon Date: Thu, 26 Mar 2026 14:55:32 +0100 Subject: [PATCH 1/3] feat: implement real-time notifications with graphql subscriptions --- components/global-navbar.tsx | 7 +- .../notifications/notification-center.tsx | 112 +++++++++++ hooks/use-graphql-subscription.ts | 111 ++++++----- hooks/use-notifications.ts | 125 ++++++++++++ lib/graphql/subscriptions.ts | 183 +++++++++++------- lib/store.test.ts | 2 - 6 files changed, 415 insertions(+), 125 deletions(-) create mode 100644 components/notifications/notification-center.tsx create mode 100644 hooks/use-notifications.ts diff --git a/components/global-navbar.tsx b/components/global-navbar.tsx index ef50d75..c7d26f6 100644 --- a/components/global-navbar.tsx +++ b/components/global-navbar.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { SearchCommand } from "@/components/search-command"; import { NavRankBadge } from "@/components/leaderboard/nav-rank-badge"; +import { NotificationCenter } from "@/components/notifications/notification-center"; import { WalletSheet } from "@/components/wallet/wallet-sheet"; import { mockWalletInfo } from "@/lib/mock-wallet"; import { Button } from "@/components/ui/button"; @@ -61,8 +62,8 @@ export function GlobalNavbar() { href="/transparency" className={`transition-colors hover:text-foreground/80 ${ pathname.startsWith("/transparency") - ? "text-foreground" - : "text-foreground/60" + ? "text-foreground" + : "text-foreground/60" }`} > Transparency @@ -94,6 +95,8 @@ export function GlobalNavbar() { {/* TODO: Replace with actual auth user ID */} + + + + + + + +
+
+
Notifications
+
+ Real-time bounty and application activity. +
+
+ + +
+ +
+ {isLoading ? ( +
+
+
+
+
+ ) : notifications.length === 0 ? ( +
+ No notifications yet. New activity will appear here instantly. +
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} +
+ + + ); +} diff --git a/hooks/use-graphql-subscription.ts b/hooks/use-graphql-subscription.ts index 73582f7..ecd150a 100644 --- a/hooks/use-graphql-subscription.ts +++ b/hooks/use-graphql-subscription.ts @@ -1,53 +1,58 @@ -import { useEffect, useRef } from 'react'; -import { wsClient } from '@/lib/graphql/ws-client'; -import { type DocumentNode, print } from 'graphql'; - -/** - * Generic GraphQL Subscription Hook. - * Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount. - * - * @template T - The response shape of the subscription - * @param query - The subscription document (gql DocumentNode or string) - * @param variables - Subscription variables - * @param onData - Callback triggered on each data event - * @param onError - Optional error callback - */ -export function useGraphQLSubscription( - query: DocumentNode | string, - variables: Record, - onData: (data: T) => void, - onError?: (error: unknown) => void -) { - // Hold latest callbacks in refs so we don't restart the subscription when they change - const onDataRef = useRef(onData); - const onErrorRef = useRef(onError); - - useEffect(() => { - onDataRef.current = onData; - onErrorRef.current = onError; - }, [onData, onError]); - - // Track variables as a string to avoid reference-based flapping - const variablesString = JSON.stringify(variables); - - // Track query as a string - const queryString = typeof query === 'string' ? query : print(query); - - useEffect(() => { - const unsubscribe = wsClient.subscribe( - { query: queryString, variables: JSON.parse(variablesString) }, - { - next: ({ data }) => data && onDataRef.current(data), - error: (err) => { - console.error('[GraphQL Subscription] Error:', err); - onErrorRef.current?.(err); - }, - complete: () => { }, - } - ); - - return () => { - unsubscribe(); - }; - }, [queryString, variablesString]); -} +import { useEffect, useRef } from "react"; +import { wsClient } from "@/lib/graphql/ws-client"; +import { type DocumentNode, print } from "graphql"; + +/** + * Generic GraphQL Subscription Hook. + * Manages the lifecycle of a graphql-ws subscription, ensuring cleanup on unmount. + * + * @template T - The response shape of the subscription + * @param query - The subscription document (gql DocumentNode or string) + * @param variables - Subscription variables + * @param onData - Callback triggered on each data event + * @param onError - Optional error callback + */ +export function useGraphQLSubscription( + query: DocumentNode | string, + variables: Record, + onData: (data: T) => void, + onError?: (error: unknown) => void, + enabled = true, +) { + // Hold latest callbacks in refs so we don't restart the subscription when they change + const onDataRef = useRef(onData); + const onErrorRef = useRef(onError); + + useEffect(() => { + onDataRef.current = onData; + onErrorRef.current = onError; + }, [onData, onError]); + + // Track variables as a string to avoid reference-based flapping + const variablesString = JSON.stringify(variables); + + // Track query as a string + const queryString = typeof query === "string" ? query : print(query); + + useEffect(() => { + if (!enabled) { + return; + } + + const unsubscribe = wsClient.subscribe( + { query: queryString, variables: JSON.parse(variablesString) }, + { + next: ({ data }) => data && onDataRef.current(data), + error: (err) => { + console.error("[GraphQL Subscription] Error:", err); + onErrorRef.current?.(err); + }, + complete: () => {}, + }, + ); + + return () => { + unsubscribe(); + }; + }, [enabled, queryString, variablesString]); +} diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts new file mode 100644 index 0000000..1aaecdb --- /dev/null +++ b/hooks/use-notifications.ts @@ -0,0 +1,125 @@ +"use client"; + +import { useMemo, useState } from "react"; + +import { authClient } from "@/lib/auth-client"; +import { + ON_BOUNTY_UPDATED_SUBSCRIPTION, + ON_NEW_APPLICATION_SUBSCRIPTION, + type OnBountyUpdatedData, + type OnNewApplicationData, +} from "@/lib/graphql/subscriptions"; + +import { useGraphQLSubscription } from "./use-graphql-subscription"; + +export type NotificationType = "bounty-updated" | "new-application"; + +export interface NotificationItem { + id: string; + message: string; + type: NotificationType; + timestamp: string; + read: boolean; +} + +const MAX_NOTIFICATIONS = 25; + +function normaliseTimestamp(value?: string | null): string { + return value ? new Date(value).toISOString() : new Date().toISOString(); +} + +function notificationKey(item: Pick): string { + return `${item.type}:${item.id}`; +} + +function upsertNotification( + previous: NotificationItem[], + incoming: NotificationItem, +): NotificationItem[] { + const key = notificationKey(incoming); + const next = previous.filter((item) => notificationKey(item) !== key); + + next.unshift({ ...incoming, read: false }); + next.sort( + (left, right) => + new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(), + ); + + return next.slice(0, MAX_NOTIFICATIONS); +} + +export function useNotifications() { + const { data: session } = authClient.useSession(); + const [notifications, setNotifications] = useState([]); + + const isEnabled = Boolean(session?.user); + const isLoading = session === undefined; + + useGraphQLSubscription( + ON_BOUNTY_UPDATED_SUBSCRIPTION, + {}, + (data) => { + const bounty = data.bountyUpdated; + + setNotifications((previous) => + upsertNotification(previous, { + id: bounty.id, + message: `Bounty \"${bounty.title}\" was updated.`, + type: "bounty-updated", + timestamp: normaliseTimestamp(bounty.updatedAt), + read: false, + }), + ); + }, + undefined, + isEnabled, + ); + + useGraphQLSubscription( + ON_NEW_APPLICATION_SUBSCRIPTION, + {}, + (data) => { + const application = data.submissionCreated; + const actor = application.submittedByUser?.name || "A contributor"; + + setNotifications((previous) => + upsertNotification(previous, { + id: application.id, + message: `${actor} submitted a new application for bounty ${application.bountyId}.`, + type: "new-application", + timestamp: normaliseTimestamp(application.createdAt), + read: false, + }), + ); + }, + undefined, + isEnabled, + ); + + const unreadCount = useMemo( + () => notifications.reduce((count, item) => count + (item.read ? 0 : 1), 0), + [notifications], + ); + + const markAsRead = (id: string, type: NotificationType) => { + setNotifications((previous) => + previous.map((item) => + item.id === id && item.type === type ? { ...item, read: true } : item, + ), + ); + }; + + const markAllAsRead = () => { + setNotifications((previous) => + previous.map((item) => ({ ...item, read: true })), + ); + }; + + return { + notifications, + isLoading, + unreadCount, + markAsRead, + markAllAsRead, + }; +} diff --git a/lib/graphql/subscriptions.ts b/lib/graphql/subscriptions.ts index 525e1a6..427493b 100644 --- a/lib/graphql/subscriptions.ts +++ b/lib/graphql/subscriptions.ts @@ -1,68 +1,115 @@ -import { gql } from 'graphql-tag'; - -/** - * GraphQL Subscription Documents for real-time bounty events. - */ - -export const BOUNTY_CREATED_SUBSCRIPTION = gql` - subscription BountyCreated { - bountyCreated { - id - title - status - rewardAmount - rewardCurrency - } - } -`; - -export const BOUNTY_UPDATED_SUBSCRIPTION = gql` - subscription BountyUpdated { - bountyUpdated { - id - title - status - rewardAmount - rewardCurrency - } - } -`; - -export const BOUNTY_DELETED_SUBSCRIPTION = gql` - subscription BountyDeleted { - bountyDeleted { - id - } - } -`; - -/** - * Type definitions for subscription response data. - * These ensure strict typing throughout the sync layer. - */ - -export interface BountyCreatedData { - bountyCreated: { - id: string; - title: string; - status: string; - rewardAmount: number; - rewardCurrency: string; - }; -} - -export interface BountyUpdatedData { - bountyUpdated: { - id: string; - title: string; - status: string; - rewardAmount: number; - rewardCurrency: string; - }; -} - -export interface BountyDeletedData { - bountyDeleted: { - id: string; - }; -} +import { gql } from "graphql-tag"; + +/** + * GraphQL Subscription Documents for real-time bounty events. + */ + +export const BOUNTY_CREATED_SUBSCRIPTION = gql` + subscription BountyCreated { + bountyCreated { + id + title + status + rewardAmount + rewardCurrency + } + } +`; + +export const ON_BOUNTY_UPDATED_SUBSCRIPTION = gql` + subscription OnBountyUpdated { + bountyUpdated { + id + title + status + rewardAmount + rewardCurrency + updatedAt + } + } +`; + +export const BOUNTY_UPDATED_SUBSCRIPTION = ON_BOUNTY_UPDATED_SUBSCRIPTION; + +export const ON_NEW_APPLICATION_SUBSCRIPTION = gql` + subscription OnNewApplication { + submissionCreated { + id + bountyId + submittedBy + status + createdAt + submittedByUser { + id + name + image + } + } + } +`; + +export const BOUNTY_DELETED_SUBSCRIPTION = gql` + subscription BountyDeleted { + bountyDeleted { + id + } + } +`; + +/** + * Type definitions for subscription response data. + * These ensure strict typing throughout the sync layer. + */ + +export interface BountyCreatedData { + bountyCreated: { + id: string; + title: string; + status: string; + rewardAmount: number; + rewardCurrency: string; + }; +} + +export interface BountyUpdatedData { + bountyUpdated: { + id: string; + title: string; + status: string; + rewardAmount: number; + rewardCurrency: string; + updatedAt?: string | null; + }; +} + +export interface OnBountyUpdatedData { + bountyUpdated: { + id: string; + title: string; + status: string; + rewardAmount: number; + rewardCurrency: string; + updatedAt?: string | null; + }; +} + +export interface OnNewApplicationData { + submissionCreated: { + id: string; + bountyId: string; + submittedBy: string; + status: string; + createdAt: string; + submittedByUser?: { + id: string; + name?: string | null; + image?: string | null; + } | null; + }; +} + +export interface BountyDeletedData { + bountyDeleted: { + id: string; + }; +} diff --git a/lib/store.test.ts b/lib/store.test.ts index 34cfd65..fa76311 100644 --- a/lib/store.test.ts +++ b/lib/store.test.ts @@ -45,8 +45,6 @@ describe("BountyStore", () => { bountyId: "b-2", contributorId: "u-2", content: "My work", - explanation: "My work", - walletAddress: "GABC1234567890EXAMPLEWALLETADDRESS", status: "pending", submittedAt: new Date().toISOString(), }; From 686902e593ad18c381fa926bc75fddcbebf53b19 Mon Sep 17 00:00:00 2001 From: 0xDeon Date: Thu, 26 Mar 2026 15:22:40 +0100 Subject: [PATCH 2/3] refactor: address CodeRabbit review suggestions - Replace duplicate OnBountyUpdatedData interface with type alias to BountyUpdatedData - Harden normaliseTimestamp against invalid date strings (fallback to now) - Clear notification state on auth user change to prevent cross-user leakage --- hooks/use-notifications.ts | 15 +++++++++++++-- lib/graphql/subscriptions.ts | 11 +---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/hooks/use-notifications.ts b/hooks/use-notifications.ts index 1aaecdb..6a96dc1 100644 --- a/hooks/use-notifications.ts +++ b/hooks/use-notifications.ts @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { authClient } from "@/lib/auth-client"; import { @@ -25,7 +25,11 @@ export interface NotificationItem { const MAX_NOTIFICATIONS = 25; function normaliseTimestamp(value?: string | null): string { - return value ? new Date(value).toISOString() : new Date().toISOString(); + if (!value) return new Date().toISOString(); + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) + ? new Date().toISOString() + : parsed.toISOString(); } function notificationKey(item: Pick): string { @@ -54,6 +58,13 @@ export function useNotifications() { const isEnabled = Boolean(session?.user); const isLoading = session === undefined; + const userId = session?.user?.id ?? null; + const prevUserIdRef = useRef(userId); + + if (prevUserIdRef.current !== userId) { + prevUserIdRef.current = userId; + setNotifications([]); + } useGraphQLSubscription( ON_BOUNTY_UPDATED_SUBSCRIPTION, diff --git a/lib/graphql/subscriptions.ts b/lib/graphql/subscriptions.ts index 427493b..eac810d 100644 --- a/lib/graphql/subscriptions.ts +++ b/lib/graphql/subscriptions.ts @@ -82,16 +82,7 @@ export interface BountyUpdatedData { }; } -export interface OnBountyUpdatedData { - bountyUpdated: { - id: string; - title: string; - status: string; - rewardAmount: number; - rewardCurrency: string; - updatedAt?: string | null; - }; -} +export type OnBountyUpdatedData = BountyUpdatedData; export interface OnNewApplicationData { submissionCreated: { From 45ed3d9eae7a658e887a6787176c73a07d6f0a87 Mon Sep 17 00:00:00 2001 From: 0xDeon Date: Thu, 26 Mar 2026 15:32:43 +0100 Subject: [PATCH 3/3] test: update subscription operation name in bounty subscription test Align test assertion with renamed OnBountyUpdated operation name --- .../__tests__/use-bounty-subscription.test.ts | 353 ++++++++++-------- 1 file changed, 191 insertions(+), 162 deletions(-) diff --git a/hooks/__tests__/use-bounty-subscription.test.ts b/hooks/__tests__/use-bounty-subscription.test.ts index c8b2bda..4e87e9a 100644 --- a/hooks/__tests__/use-bounty-subscription.test.ts +++ b/hooks/__tests__/use-bounty-subscription.test.ts @@ -1,162 +1,191 @@ -import { renderHook } from '@testing-library/react'; -import { useQueryClient } from '@tanstack/react-query'; -import { useBountySubscription } from '../use-bounty-subscription'; -import { wsClient } from '@/lib/graphql/ws-client'; -import { - BOUNTY_CREATED_SUBSCRIPTION, - BOUNTY_UPDATED_SUBSCRIPTION, - BOUNTY_DELETED_SUBSCRIPTION, -} from '@/lib/graphql/subscriptions'; -import { bountyKeys } from '@/lib/query/query-keys'; - -// Mock dependencies -jest.mock('@tanstack/react-query', () => ({ - useQueryClient: jest.fn(), -})); - -jest.mock('@/lib/graphql/ws-client', () => ({ - wsClient: { - subscribe: jest.fn(), - }, -})); - -// Mock query keys factory -jest.mock('@/lib/query/query-keys', () => ({ - bountyKeys: { - lists: jest.fn(() => ['Bounties', 'lists']), - detail: jest.fn((id: string) => ['Bounty', { id }]), - allListKeys: [ - ['Bounties', 'lists'], - ['ActiveBounties'], - ['OrganizationBounties'], - ['ProjectBounties'], - ], - }, -})); - -// Mock graphql-tag -jest.mock('graphql-tag', () => ({ - gql: (strings: string[]) => ({ - kind: 'Document', - definitions: [], - loc: { source: { body: strings[0].trim() } }, - }), -})); - -// Mock graphql print -jest.mock('graphql', () => ({ - print: jest.fn((query: { loc?: { source?: { body: string } } }) => query.loc?.source?.body ?? ''), -})); - -describe('useBountySubscription', () => { - let mockInvalidateQueries: jest.Mock; - let mockRemoveQueries: jest.Mock; - let mockSubscribe: jest.Mock; - const mockUnsubscribe = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - mockInvalidateQueries = jest.fn(); - mockRemoveQueries = jest.fn(); - (useQueryClient as jest.Mock).mockReturnValue({ - invalidateQueries: mockInvalidateQueries, - removeQueries: mockRemoveQueries, - }); - - mockSubscribe = wsClient.subscribe as jest.Mock; - mockSubscribe.mockReturnValue(mockUnsubscribe); - }); - - it('should subscribe to all three bounty events on mount', () => { - renderHook(() => useBountySubscription()); - - expect(mockSubscribe).toHaveBeenCalledTimes(3); - - // Check if it subscribes with the correct queries - const subscribedQueries = mockSubscribe.mock.calls.map(call => call[0].query); - expect(subscribedQueries).toContain((BOUNTY_CREATED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); - expect(subscribedQueries).toContain((BOUNTY_UPDATED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); - expect(subscribedQueries).toContain((BOUNTY_DELETED_SUBSCRIPTION as unknown as { loc: { source: { body: string } } }).loc.source.body); - }); - - it('should cleanup subscriptions on unmount', () => { - const { unmount } = renderHook(() => useBountySubscription()); - unmount(); - expect(mockUnsubscribe).toHaveBeenCalledTimes(3); - }); - - it('should invalidate lists when bountyCreated arrives', () => { - renderHook(() => useBountySubscription()); - - // Find the call for bountyCreated and trigger its callback - const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyCreated')); - const callback = call[1].next; - - // Simulate incoming data - callback({ - data: { - bountyCreated: { - id: 'bounty-1', - title: 'New Bounty', - status: 'OPEN', - rewardAmount: 100, - rewardCurrency: 'XLM' - } - } - }); - - expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); // Once for each entry in allListKeys - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ['Bounties', 'lists'] - }); - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: ['ActiveBounties'] - }); - }); - - it('should invalidate detail and lists when bountyUpdated arrives', () => { - renderHook(() => useBountySubscription()); - - // Find the call for bountyUpdated and trigger its callback - const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyUpdated')); - const callback = call[1].next; - - // Simulate incoming data - callback({ - data: { - bountyUpdated: { - id: 'bounty-1', - title: 'Updated Bounty', - status: 'IN_PROGRESS', - rewardAmount: 200, - rewardCurrency: 'XLM' - } - } - }); - - expect(mockInvalidateQueries).toHaveBeenCalledWith({ - queryKey: bountyKeys.detail('bounty-1') - }); - expect(mockInvalidateQueries).toHaveBeenCalledTimes(5); // 4 for lists + 1 for detail - }); - - it('should remove detail and invalidate lists when bountyDeleted arrives', () => { - renderHook(() => useBountySubscription()); - - // Find the call for bountyDeleted and trigger its callback - const call = mockSubscribe.mock.calls.find(c => c[0].query.includes('subscription BountyDeleted')); - const callback = call[1].next; - - // Simulate incoming data - callback({ - data: { - bountyDeleted: { id: 'bounty-1' } - } - }); - - expect(mockRemoveQueries).toHaveBeenCalledWith({ - queryKey: bountyKeys.detail('bounty-1') - }); - expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); - }); -}); +import { renderHook } from "@testing-library/react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useBountySubscription } from "../use-bounty-subscription"; +import { wsClient } from "@/lib/graphql/ws-client"; +import { + BOUNTY_CREATED_SUBSCRIPTION, + BOUNTY_UPDATED_SUBSCRIPTION, + BOUNTY_DELETED_SUBSCRIPTION, +} from "@/lib/graphql/subscriptions"; +import { bountyKeys } from "@/lib/query/query-keys"; + +// Mock dependencies +jest.mock("@tanstack/react-query", () => ({ + useQueryClient: jest.fn(), +})); + +jest.mock("@/lib/graphql/ws-client", () => ({ + wsClient: { + subscribe: jest.fn(), + }, +})); + +// Mock query keys factory +jest.mock("@/lib/query/query-keys", () => ({ + bountyKeys: { + lists: jest.fn(() => ["Bounties", "lists"]), + detail: jest.fn((id: string) => ["Bounty", { id }]), + allListKeys: [ + ["Bounties", "lists"], + ["ActiveBounties"], + ["OrganizationBounties"], + ["ProjectBounties"], + ], + }, +})); + +// Mock graphql-tag +jest.mock("graphql-tag", () => ({ + gql: (strings: string[]) => ({ + kind: "Document", + definitions: [], + loc: { source: { body: strings[0].trim() } }, + }), +})); + +// Mock graphql print +jest.mock("graphql", () => ({ + print: jest.fn( + (query: { loc?: { source?: { body: string } } }) => + query.loc?.source?.body ?? "", + ), +})); + +describe("useBountySubscription", () => { + let mockInvalidateQueries: jest.Mock; + let mockRemoveQueries: jest.Mock; + let mockSubscribe: jest.Mock; + const mockUnsubscribe = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + mockInvalidateQueries = jest.fn(); + mockRemoveQueries = jest.fn(); + (useQueryClient as jest.Mock).mockReturnValue({ + invalidateQueries: mockInvalidateQueries, + removeQueries: mockRemoveQueries, + }); + + mockSubscribe = wsClient.subscribe as jest.Mock; + mockSubscribe.mockReturnValue(mockUnsubscribe); + }); + + it("should subscribe to all three bounty events on mount", () => { + renderHook(() => useBountySubscription()); + + expect(mockSubscribe).toHaveBeenCalledTimes(3); + + // Check if it subscribes with the correct queries + const subscribedQueries = mockSubscribe.mock.calls.map( + (call) => call[0].query, + ); + expect(subscribedQueries).toContain( + ( + BOUNTY_CREATED_SUBSCRIPTION as unknown as { + loc: { source: { body: string } }; + } + ).loc.source.body, + ); + expect(subscribedQueries).toContain( + ( + BOUNTY_UPDATED_SUBSCRIPTION as unknown as { + loc: { source: { body: string } }; + } + ).loc.source.body, + ); + expect(subscribedQueries).toContain( + ( + BOUNTY_DELETED_SUBSCRIPTION as unknown as { + loc: { source: { body: string } }; + } + ).loc.source.body, + ); + }); + + it("should cleanup subscriptions on unmount", () => { + const { unmount } = renderHook(() => useBountySubscription()); + unmount(); + expect(mockUnsubscribe).toHaveBeenCalledTimes(3); + }); + + it("should invalidate lists when bountyCreated arrives", () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyCreated and trigger its callback + const call = mockSubscribe.mock.calls.find((c) => + c[0].query.includes("subscription BountyCreated"), + ); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyCreated: { + id: "bounty-1", + title: "New Bounty", + status: "OPEN", + rewardAmount: 100, + rewardCurrency: "XLM", + }, + }, + }); + + expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); // Once for each entry in allListKeys + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ["Bounties", "lists"], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ["ActiveBounties"], + }); + }); + + it("should invalidate detail and lists when bountyUpdated arrives", () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyUpdated and trigger its callback + const call = mockSubscribe.mock.calls.find((c) => + c[0].query.includes("subscription OnBountyUpdated"), + ); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyUpdated: { + id: "bounty-1", + title: "Updated Bounty", + status: "IN_PROGRESS", + rewardAmount: 200, + rewardCurrency: "XLM", + }, + }, + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: bountyKeys.detail("bounty-1"), + }); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(5); // 4 for lists + 1 for detail + }); + + it("should remove detail and invalidate lists when bountyDeleted arrives", () => { + renderHook(() => useBountySubscription()); + + // Find the call for bountyDeleted and trigger its callback + const call = mockSubscribe.mock.calls.find((c) => + c[0].query.includes("subscription BountyDeleted"), + ); + const callback = call[1].next; + + // Simulate incoming data + callback({ + data: { + bountyDeleted: { id: "bounty-1" }, + }, + }); + + expect(mockRemoveQueries).toHaveBeenCalledWith({ + queryKey: bountyKeys.detail("bounty-1"), + }); + expect(mockInvalidateQueries).toHaveBeenCalledTimes(4); + }); +});