Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { act } from 'react';
import { waitFor } from '@testing-library/react';
import SendbirdChat from '@sendbird/chat';
import type { SendbirdProviderProps } from '../index';
import type { SessionHandler } from '@sendbird/chat';
import { renderWithSendbird } from '../../../utils/testMocks/renderWithSendbird';

// Mount the REAL SendbirdProvider and mock ONLY the '@sendbird/chat' boundary, so the whole
// prop -> SendbirdContextManager -> useSendbird.connect -> initSDK/setupSDK chain runs. This
// proves no layer drops/mutates a customer-provided value on its way to the SDK — the seam the
// existing segment tests (utils.spec.ts / useSendbird.spec.tsx / SendbirdProvider.spec.tsx) skip.
vi.mock('@sendbird/chat', async () => (
await import('../../../utils/testMocks/sendbirdChat')
).createSendbirdChatMock());

const sdk = SendbirdChat as any;

const mountProvider = async (props: Partial<SendbirdProviderProps>) => {
await act(async () => {
renderWithSendbird(<div />, props);
});
};

describe('SendbirdProvider — SDK init/connect consistency (integration)', () => {
const originalConsoleError = globalThis.console.error.bind(globalThis.console);

beforeAll(() => {
// The connect() effect resolves async after mount; silence the RTL act warning noise.
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
originalConsoleError(...args);
});
});

afterAll(() => {
vi.restoreAllMocks();
});

beforeEach(() => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});

it('initializes SendbirdChat with the customer-provided connection params + sdkInitParams', async () => {
await mountProvider({
appId: 'test-app-id',
userId: 'test-user-id',
accessToken: 'test-access-token',
customApiHost: 'https://api.custom',
customWebSocketHost: 'wss://ws.custom',
sdkInitParams: { localCacheEnabled: false },
});

await waitFor(() => expect(sdk.init).toHaveBeenCalledWith(
expect.objectContaining({
appId: 'test-app-id',
customApiHost: 'https://api.custom',
customWebSocketHost: 'wss://ws.custom',
localCacheEnabled: false, // sdkInitParams override survives the whole chain
modules: expect.any(Array),
}),
));
});

it('connects with exactly (userId, accessToken)', async () => {
await mountProvider({ appId: 'test-app-id', userId: 'user-42', accessToken: 'token-abc' });

await waitFor(() => expect(sdk.connect).toHaveBeenCalledWith('user-42', 'token-abc'));
});

it('updates current user info with the provided nickname/profileUrl', async () => {
await mountProvider({
appId: 'test-app-id',
userId: 'user-42',
nickname: 'Alice',
profileUrl: 'https://img/alice.png',
});

await waitFor(() => expect(sdk.updateCurrentUserInfo).toHaveBeenCalledWith(
expect.objectContaining({ nickname: 'Alice', profileUrl: 'https://img/alice.png' }),
));
});

it('does NOT update current user info when neither nickname nor profileUrl is provided', async () => {
await mountProvider({ appId: 'test-app-id', userId: 'user-42' });

await waitFor(() => expect(sdk.connect).toHaveBeenCalled());
// let the post-connect continuation (the nickname/profileUrl decision) run
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

expect(sdk.updateCurrentUserInfo).not.toHaveBeenCalled();
});

it('passes setupSDK extensions, platform, customExtensionParams and session handler through', async () => {
const sessionHandler = { onSessionExpired: vi.fn() } as unknown as SessionHandler;
const configureSession = vi.fn(() => sessionHandler);

await mountProvider({
appId: 'test-app-id',
userId: 'user-42',
customExtensionParams: { feature: 'custom' },
configureSession,
});

await waitFor(() => expect(sdk.addSendbirdExtensions).toHaveBeenCalled());
expect(sdk.addExtension).toHaveBeenCalledWith('sb_uikit', expect.any(String));
expect(sdk.addSendbirdExtensions).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({ platform: 'WEB' }),
{ feature: 'custom' },
);
expect(configureSession).toHaveBeenCalledWith(sdk);
expect(sdk.setSessionHandler).toHaveBeenCalledWith(sessionHandler);
});

it('initializes as a new instance on first mount', async () => {
await mountProvider({ appId: 'test-app-id', userId: 'user-42' });

await waitFor(() => expect(sdk.init).toHaveBeenCalledWith(
expect.objectContaining({ newInstance: true }),
));
});

it('calls eventHandlers.connection.onConnected with the connected user', async () => {
const onConnected = vi.fn();
await mountProvider({ appId: 'test-app-id', userId: 'user-42', eventHandlers: { connection: { onConnected } } });

await waitFor(() => expect(onConnected).toHaveBeenCalledWith(expect.objectContaining({ userId: 'test-user-id' })));
});

it('calls eventHandlers.connection.onFailed on connect failure and skips updateCurrentUserInfo', async () => {
const error = new Error('connect failed');
sdk.connect.mockRejectedValueOnce(error);
const onFailed = vi.fn();
await mountProvider({
appId: 'test-app-id',
userId: 'user-42',
nickname: 'Alice',
eventHandlers: { connection: { onFailed } },
});

await waitFor(() => expect(onFailed).toHaveBeenCalledWith(error));
expect(sdk.updateCurrentUserInfo).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React, { act } from 'react';
import { render, screen } from '@testing-library/react';
import SendbirdProvider from '../index';
import { useLocalization } from '../../LocalizationContext';
import getStringSet from '../../../ui/Label/stringSet';

vi.mock('@sendbird/chat', async () => (
await import('../../../utils/testMocks/sendbirdChat')
).createSendbirdChatMock());

const en = getStringSet('en') as unknown as Record<string, string>;
const nonEmptyKeys = Object.keys(en).filter((k) => typeof en[k] === 'string' && en[k].length > 0);
const OVERRIDE_KEY = nonEmptyKeys[0];
const DEFAULT_KEY = nonEmptyKeys[1];

const LocalizationProbe = () => {
const { stringSet } = useLocalization();
return (
<>
<div data-testid="override">{(stringSet as unknown as Record<string, string>)[OVERRIDE_KEY]}</div>
<div data-testid="default">{(stringSet as unknown as Record<string, string>)[DEFAULT_KEY]}</div>
</>
);
};

describe('SendbirdProvider — theme & stringSet propagation (integration)', () => {
const originalConsoleError = globalThis.console.error.bind(globalThis.console);

beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
originalConsoleError(...args);
});
});

afterAll(() => {
vi.restoreAllMocks();
});

beforeEach(() => {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});

it('applies the theme class to <body> and toggles it when the theme prop changes', async () => {
let view!: ReturnType<typeof render>;
await act(async () => {
view = render(
<SendbirdProvider appId="test-app-id" userId="test-user-id" theme="dark">
<div />
</SendbirdProvider>,
);
});

expect(document.body.classList.contains('sendbird-theme--dark')).toBe(true);
expect(document.body.classList.contains('sendbird-theme--light')).toBe(false);

await act(async () => {
view.rerender(
<SendbirdProvider appId="test-app-id" userId="test-user-id" theme="light">
<div />
</SendbirdProvider>,
);
});

expect(document.body.classList.contains('sendbird-theme--light')).toBe(true);
expect(document.body.classList.contains('sendbird-theme--dark')).toBe(false);
});

it('merges a custom stringSet over the English defaults (does not replace)', async () => {
const customValue = 'CUSTOM_STRING_VALUE';
await act(async () => {
render(
<SendbirdProvider
appId="test-app-id"
userId="test-user-id"
stringSet={{ [OVERRIDE_KEY]: customValue } as any}
>
<LocalizationProbe />
</SendbirdProvider>,
);
});

// overridden key uses the customer's value
expect(screen.getByTestId('override')).toHaveTextContent(customValue);
// an un-overridden key still resolves to the English default (proves merge, not replace)
expect(screen.getByTestId('default')).toHaveTextContent(en[DEFAULT_KEY]);
});

it('propagates a custom dateLocale to the localization context', async () => {
const customDateLocale = { code: 'xx-custom' } as any;
let received: unknown;
const DateLocaleProbe = () => {
received = useLocalization().dateLocale;
return null;
};
await act(async () => {
render(
<SendbirdProvider appId="test-app-id" userId="test-user-id" dateLocale={customDateLocale}>
<DateLocaleProbe />
</SendbirdProvider>,
);
});

// the customer's date-fns locale must reach useLocalization().dateLocale unchanged
expect(received).toBe(customDateLocale);
});
});
69 changes: 69 additions & 0 deletions src/modules/App/__tests__/App.connectionProps.integration.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from '../index';
import Sendbird from '../../../lib/Sendbird';

// App is a thin wrapper that forwards the customer's connection/init props to <Sendbird>
// (SendbirdProvider) and renders <AppLayout> as children. This proves the App -> SendbirdProvider
// half of the chain (App does not drop/mutate a customer connection prop). The provider -> SDK
// init/connect half is covered by SendbirdProvider.sdkInit.integration.spec.tsx.
vi.mock('../../../lib/Sendbird', () => ({
__esModule: true,
// Spy that captures the props App hands down; returns null so AppLayout never mounts.
default: vi.fn(() => null),
}));

const lastSendbirdProps = () => {
const calls = vi.mocked(Sendbird).mock.calls;
return calls[calls.length - 1][0] as any;
};

describe('App — connection prop passthrough to SendbirdProvider (integration)', () => {
it('forwards the customer connection/init props to SendbirdProvider unchanged', () => {
const eventHandlers = { connection: { onConnected: vi.fn() } };
const sdkInitParams = { localCacheEnabled: false };
const customExtensionParams = { feature: 'custom' };

render(
<App
appId="test-app-id"
userId="user-42"
accessToken="token-abc"
customApiHost="https://api.custom"
customWebSocketHost="wss://ws.custom"
nickname="Alice"
profileUrl="https://img/alice.png"
sdkInitParams={sdkInitParams}
customExtensionParams={customExtensionParams}
eventHandlers={eventHandlers}
/>,
);

expect(lastSendbirdProps()).toEqual(expect.objectContaining({
appId: 'test-app-id',
userId: 'user-42',
accessToken: 'token-abc',
customApiHost: 'https://api.custom',
customWebSocketHost: 'wss://ws.custom',
nickname: 'Alice',
profileUrl: 'https://img/alice.png',
sdkInitParams,
customExtensionParams,
eventHandlers,
}));
});

it('applies empty-string defaults for optional connection props when omitted', () => {
render(<App appId="test-app-id" userId="user-42" />);

expect(lastSendbirdProps()).toEqual(expect.objectContaining({
appId: 'test-app-id',
userId: 'user-42',
accessToken: '',
customApiHost: '',
customWebSocketHost: '',
nickname: '',
profileUrl: '',
}));
});
});
Loading