import { StreamChatService } from '@linktr.ee/messaging-core' import type { Logger, MessagingUser, StreamChatServiceConfig, } from '@linktr.ee/messaging-core' import { render, waitFor, act } from '@testing-library/react' import React from 'react' import type { StreamChat } from 'stream-chat' import { describe, it, expect, vi, beforeEach } from 'vitest' import { useMessagingLogger } from '../logging' import { MessagingProvider, useMessagingContext } from './MessagingProvider' // Stub stream-chat-react's so we don't need a real, fully-wired // StreamChat instance just to verify provider state. vi.mock('stream-chat-react', () => ({ Chat: (props: { children: React.ReactNode }) => <>{props.children}, })) // The provider constructs the Chat client via `new StreamChat(apiKey)` and // injects it into the service. Stub the constructor so sees an instance. vi.mock('stream-chat', () => ({ StreamChat: vi.fn().mockImplementation(function () { return { userID: 'mock' } }), })) const setupServiceMock = () => { const connectUser = vi.fn( async () => ({ userID: 'mock' }) as unknown as StreamChat ) const disconnectUser = vi.fn(async () => undefined) vi.spyOn(StreamChatService.prototype, 'connectUser').mockImplementation( connectUser ) vi.spyOn(StreamChatService.prototype, 'disconnectUser').mockImplementation( disconnectUser ) return { connectUser, disconnectUser } } const Probe: React.FC<{ onState: (state: unknown) => void }> = ({ onState, }) => { const ctx = useMessagingContext() React.useEffect(() => { onState({ isConnected: ctx.isConnected, isLoading: ctx.isLoading, error: ctx.error, hasClient: !!ctx.client, }) }, [ctx, onState]) return null } describe('MessagingProvider', () => { beforeEach(() => { vi.restoreAllMocks() }) it('connects a guest user through the underlying service', async () => { const { connectUser } = setupServiceMock() const states: unknown[] = [] const guestUser = { type: 'guest' as const, id: 'guest-1', name: 'Guest', } as MessagingUser render( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} > states.push(s)} /> ) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) expect(connectUser).toHaveBeenCalledWith(guestUser) await waitFor(() => { const last = states[states.length - 1] as { isConnected: boolean } expect(last.isConnected).toBe(true) }) }) it('mounts children once across the connect transition (no subtree remount)', async () => { setupServiceMock() let mountCount = 0 const MountCounter: React.FC = () => { React.useEffect(() => { mountCount += 1 }, []) return null } const states: unknown[] = [] render( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} > states.push(s)} /> ) // Wait until the connection has resolved — this is the point where the old // implementation swapped `children` into and remounted the subtree. await waitFor(() => { const last = states[states.length - 1] as { isConnected: boolean } expect(last.isConnected).toBe(true) }) expect(mountCount).toBe(1) }) it('does not disconnect the reused client when only serviceConfig changes', async () => { const { connectUser, disconnectUser } = setupServiceMock() const user = { type: 'guest' as const, id: 'guest-1', name: 'Guest', } as MessagingUser const { rerender } = render( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} >
) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) // Re-render with a NEW inline serviceConfig object (same apiKey). The // service is created once per client lifetime, so a serviceConfig change // neither recreates the service nor tears down the live connection. await act(async () => { rerender( 'token', createChannel: async () => ({ channelId: 'ch-2' }), }} >
) }) expect(disconnectUser).not.toHaveBeenCalled() }) it('does not disconnect the reused client when only the debug prop toggles', async () => { const { connectUser, disconnectUser } = setupServiceMock() const user = { type: 'guest' as const, id: 'guest-1', name: 'Guest', } as MessagingUser const serviceConfig = { fetchToken: async () => 'token', createChannel: async () => ({ channelId: 'ch-1' }), } const { rerender } = render(
) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) // Toggling `debug` changes the internal debugLog identity. The client // cleanup must stay keyed on the client's lifetime, not on debugLog, so the // live connection is not torn down for a logging change. await act(async () => { rerender(
) }) expect(disconnectUser).not.toHaveBeenCalled() }) it('disconnects and reconnects when remounted with a new key', async () => { const { connectUser, disconnectUser } = setupServiceMock() const guestUser = { type: 'guest' as const, id: 'guest-1', name: 'Guest', } as MessagingUser const authedUser = { id: 'user-1', name: 'Authed User' } const { rerender } = render( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} >
) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) expect(connectUser).toHaveBeenLastCalledWith(guestUser) await act(async () => { rerender( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} >
) }) await waitFor(() => expect(disconnectUser).toHaveBeenCalled()) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2)) expect(connectUser).toHaveBeenLastCalledWith(authedUser) }) it('uses an injected client directly and never connects a service', async () => { const { connectUser, disconnectUser } = setupServiceMock() const injectedClient = { userID: 'injected-user', } as unknown as StreamChat const states: unknown[] = [] render( states.push(s)} /> ) // Injected client is reported connected immediately, and its instance is // surfaced on the context. await waitFor(() => { const last = states[states.length - 1] as { isConnected: boolean hasClient: boolean } expect(last.isConnected).toBe(true) expect(last.hasClient).toBe(true) }) // The caller owns the injected client's lifecycle — the provider must not // spin up a StreamChatService or connect/disconnect it. expect(connectUser).not.toHaveBeenCalled() expect(disconnectUser).not.toHaveBeenCalled() }) it('refreshConnection is a no-op in injected-client mode', async () => { const { connectUser, disconnectUser } = setupServiceMock() const injectedClient = { userID: 'injected-user', } as unknown as StreamChat let refresh: (() => Promise) | undefined const Capture: React.FC = () => { refresh = useMessagingContext().refreshConnection return null } render( ) await waitFor(() => expect(refresh).toBeDefined()) // A consumer calling refresh must not reach any backend — the injected // client owns its lifecycle, so refresh does nothing rather than // disconnect/reconnect a stale apiKey-path service. await act(async () => { await refresh?.() }) expect(connectUser).not.toHaveBeenCalled() expect(disconnectUser).not.toHaveBeenCalled() }) it('does not reconnect a stale service after switching to an injected client', async () => { const { connectUser } = setupServiceMock() const { rerender } = render( 'token', createChannel: async () => ({ channelId: 'ch-1' }), }} > {}} /> ) // apiKey path connects user-1 through its service. await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) const injectedClient = { userID: 'injected-user', } as unknown as StreamChat // Switch the same provider to an injected client and change the user. The // user-connection effect must not drive the new user onto the stale // apiKey-path service. rerender( {}} /> ) await act(async () => { await Promise.resolve() }) // Still only the single user-1 connect — user-2 never reached the service. expect(connectUser).toHaveBeenCalledTimes(1) }) it('drops the apiKey service on injection so returning to apiKey mode reconnects only the fresh service', async () => { const { connectUser } = setupServiceMock() const apiKeyProps = { apiKey: 'mock-api-key', serviceConfig: { fetchToken: async () => 'token', createChannel: async () => ({ channelId: 'ch-1' }), }, } const { rerender } = render( {}} /> ) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(1)) // Enter injected mode: the apiKey-path service must be dropped, not left // dangling for the user-connection effect to reconnect later. const injectedClient = { userID: 'injected-user', } as unknown as StreamChat rerender( {}} /> ) await act(async () => { await Promise.resolve() }) // Return to apiKey mode with a new user. Only the freshly created service // connects — a lingering stale service would add a third connect here. rerender( {}} /> ) await waitFor(() => expect(connectUser).toHaveBeenCalledTimes(2)) expect(connectUser).toHaveBeenLastCalledWith( expect.objectContaining({ id: 'user-2' }) ) }) it('clears apiKey client/connection state on injection so it cannot leak back to apiKey mode', async () => { setupServiceMock() const states: Array<{ isConnected: boolean hasClient: boolean error: string | null }> = [] const apiKeyProps = { apiKey: 'mock-api-key', serviceConfig: { fetchToken: async () => 'token', createChannel: async () => ({ channelId: 'ch-1' }), }, } const onState = (s: unknown) => states.push( s as { isConnected: boolean; hasClient: boolean; error: string | null } ) const { rerender } = render( ) // apiKey path connects: client + isConnected become truthy. await waitFor(() => { const last = states[states.length - 1] expect(last.isConnected).toBe(true) expect(last.hasClient).toBe(true) }) const injectedClient = { userID: 'injected-user', } as unknown as StreamChat rerender( ) await act(async () => { await Promise.resolve() }) // Return to apiKey mode with no user, so nothing reconnects. The stale // apiKey client/isConnected must have been cleared on injection, not // resurface for MessagingShell to queryChannels against. rerender( ) await act(async () => { await Promise.resolve() }) const last = states[states.length - 1] expect(last.isConnected).toBe(false) expect(last.hasClient).toBe(false) expect(last.error).toBeNull() }) describe('logger seam', () => { const ServiceProbe: React.FC<{ onService: (service: StreamChatService | null) => void }> = ({ onService }) => { const { service } = useMessagingContext() React.useEffect(() => { onService(service) }, [service, onService]) return null } const LoggerProbe: React.FC<{ onLogger: (logger: Logger) => void }> = ({ onLogger, }) => { const logger = useMessagingLogger() React.useEffect(() => { onLogger(logger) }, [logger, onLogger]) return null } // The service stores its resolved sink privately; read it to prove which // logger the provider handed to the constructor. const serviceLogger = (service: StreamChatService) => (service as unknown as { logger: Logger }).logger const renderWithLogger = async ( props: Partial> & { serviceConfig: Omit } ) => { setupServiceMock() const services: (StreamChatService | null)[] = [] const loggers: Logger[] = [] render( services.push(s)} /> loggers.push(l)} /> ) await waitFor(() => expect(services.filter(Boolean).length).toBe(1)) return { service: services.filter(Boolean).pop() as StreamChatService, logger: loggers[loggers.length - 1], } } const baseServiceConfig: Omit = { fetchToken: async () => 'token', createChannel: async () => ({ channelId: 'ch-1' }), } it('forwards the logger prop into the service and to the subtree', async () => { const sink: Logger = { error: vi.fn() } const { service, logger } = await renderWithLogger({ serviceConfig: baseServiceConfig, logger: sink, }) expect(serviceLogger(service).error).toBe(sink.error) const error = new Error('boom') logger.error?.('[MessagingShell] Failed:', error) expect(sink.error).toHaveBeenCalledWith('[MessagingShell] Failed:', error) }) it('resolves a partial prop sink before handing it to the service', async () => { const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const sink: Logger = { error: vi.fn() } const { service } = await renderWithLogger({ serviceConfig: baseServiceConfig, logger: sink, }) // The service swaps its own console defaults out wholesale, so an // unresolved partial sink would silence the levels it omits. serviceLogger(service).warn?.('[StreamChatService] Slow:', 1) expect(consoleWarn).toHaveBeenCalledWith('[StreamChatService] Slow:', 1) }) it('lets an explicit serviceConfig.logger win over the logger prop', async () => { const configSink: Logger = { error: vi.fn() } const propSink: Logger = { error: vi.fn() } const { service } = await renderWithLogger({ serviceConfig: { ...baseServiceConfig, logger: configSink }, logger: propSink, }) expect(serviceLogger(service)).toBe(configSink) }) it('leaves the service on its console defaults when no logger is given', async () => { const consoleError = vi .spyOn(console, 'error') .mockImplementation(() => {}) const { service, logger } = await renderWithLogger({ serviceConfig: baseServiceConfig, }) // Not the react seam object: the service fell back to its own defaults. expect(serviceLogger(service)).not.toBe(logger) const error = new Error('boom') logger.error?.('[MessagingShell] Failed:', error) serviceLogger(service).error?.('[StreamChatService] Failed:', error) expect(consoleError.mock.calls).toEqual([ ['[MessagingShell] Failed:', error], ['[StreamChatService] Failed:', error], ]) }) }) })