Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/hooks/useConnections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Unit tests for useConnections — focuses on the #35 realtime subscription
* that keeps the pending-connections badge live.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';

vi.mock('@/lib/logger/logger', () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));

const emptyList = {
pending_sent: [],
pending_received: [],
accepted: [],
blocked: [],
};
const getConnections = vi.fn();
vi.mock('@/services/messaging/connection-service', () => ({
connectionService: {
getConnections: (...a: unknown[]) => getConnections(...a),
},
}));

// Capture the postgres_changes handler the hook registers so we can fire it.
let changeHandler: ((payload: { eventType: string }) => void) | null = null;
const removeChannel = vi.fn();
const mockChannel = {
on: vi.fn((_evt: string, _filter: unknown, cb: typeof changeHandler) => {
changeHandler = cb;
return mockChannel;
}),
subscribe: vi.fn(() => mockChannel),
};
vi.mock('@/lib/supabase/client', () => ({
createClient: () => ({
channel: vi.fn(() => mockChannel),
removeChannel,
}),
}));

import { useConnections } from './useConnections';

describe('useConnections realtime (#35)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
changeHandler = null;
getConnections.mockResolvedValue(emptyList);
});

it('subscribes to user_connections changes on mount', async () => {
renderHook(() => useConnections());
expect(mockChannel.on).toHaveBeenCalledWith(
'postgres_changes',
expect.objectContaining({ table: 'user_connections', event: '*' }),
expect.any(Function)
);
expect(mockChannel.subscribe).toHaveBeenCalled();
vi.useRealTimers();
});

it('refetches (debounced) when a user_connections change fires', async () => {
renderHook(() => useConnections());
// 1 initial fetch on mount (flush the mount-effect microtask).
await act(async () => {
await Promise.resolve();
});
expect(getConnections).toHaveBeenCalledTimes(1);

// Fire a realtime change → debounced 1s refetch (not immediate).
act(() => {
changeHandler?.({ eventType: 'INSERT' });
});
expect(getConnections).toHaveBeenCalledTimes(1);

// Advance past the 1s debounce and flush the refetch.
await act(async () => {
vi.advanceTimersByTime(1000);
await Promise.resolve();
});
expect(getConnections).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});

it('removes the channel on unmount', () => {
const { unmount } = renderHook(() => useConnections());
unmount();
expect(removeChannel).toHaveBeenCalledWith(mockChannel);
vi.useRealTimers();
});
});
44 changes: 44 additions & 0 deletions src/hooks/useConnections.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useState, useEffect } from 'react';
import { connectionService } from '@/services/messaging/connection-service';
import { createClient } from '@/lib/supabase/client';
import { createLogger } from '@/lib/logger/logger';
import type { ConnectionList } from '@/types/messaging';

const logger = createLogger('hooks:useConnections');

export function useConnections() {
const [connections, setConnections] = useState<ConnectionList>({
pending_sent: [],
Expand Down Expand Up @@ -93,6 +97,46 @@ export function useConnections() {
fetchConnections();
}, []);

// Live-update the badge (#35): refetch on any user_connections change so the
// pending-connections count reflects accept/request/remove without a remount.
// user_connections is in the supabase_realtime publication with REPLICA
// IDENTITY FULL. Mirrors the debounced channel pattern in
// ConversationList/useConversationList.ts (1s debounce avoids re-render
// cascades from rapid-fire events); proper cleanup on unmount.
useEffect(() => {
const supabase = createClient();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const debouncedFetch = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => fetchConnections(), 1000);
};

const channel = supabase
.channel('user-connections')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'user_connections' },
(payload) => {
logger.debug('Realtime: user_connections change', {
event: payload.eventType,
});
debouncedFetch();
}
)
.subscribe((status, err) => {
if (status === 'CHANNEL_ERROR') {
logger.error('Realtime subscription failed', {
error: err?.message,
});
}
});

return () => {
if (debounceTimer) clearTimeout(debounceTimer);
supabase.removeChannel(channel);
};
}, []);

return {
connections,
loading,
Expand Down
Loading