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],
])
})
})
})