diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx new file mode 100644 index 0000000000..9d5d406f56 --- /dev/null +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx @@ -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) => { + await act(async () => { + renderWithSendbird(
, 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(); + }); +}); diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx new file mode 100644 index 0000000000..ca81c0764b --- /dev/null +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx @@ -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; +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 ( + <> +
{(stringSet as unknown as Record)[OVERRIDE_KEY]}
+
{(stringSet as unknown as Record)[DEFAULT_KEY]}
+ + ); +}; + +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 and toggles it when the theme prop changes', async () => { + let view!: ReturnType; + await act(async () => { + view = render( + +
+ , + ); + }); + + 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( + +
+ , + ); + }); + + 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( + + + , + ); + }); + + // 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( + + + , + ); + }); + + // the customer's date-fns locale must reach useLocalization().dateLocale unchanged + expect(received).toBe(customDateLocale); + }); +}); diff --git a/src/modules/App/__tests__/App.connectionProps.integration.spec.tsx b/src/modules/App/__tests__/App.connectionProps.integration.spec.tsx new file mode 100644 index 0000000000..e840037710 --- /dev/null +++ b/src/modules/App/__tests__/App.connectionProps.integration.spec.tsx @@ -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 +// (SendbirdProvider) and renders 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( + , + ); + + 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(); + + expect(lastSendbirdProps()).toEqual(expect.objectContaining({ + appId: 'test-app-id', + userId: 'user-42', + accessToken: '', + customApiHost: '', + customWebSocketHost: '', + nickname: '', + profileUrl: '', + })); + }); +}); diff --git a/src/modules/App/__tests__/AppLayout.configPrecedence.integration.spec.tsx b/src/modules/App/__tests__/AppLayout.configPrecedence.integration.spec.tsx new file mode 100644 index 0000000000..b178b567f7 --- /dev/null +++ b/src/modules/App/__tests__/AppLayout.configPrecedence.integration.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { AppLayout } from '../AppLayout'; +import { DesktopLayout } from '../DesktopLayout'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; + +// AppLayout resolves `props.X ?? dashboardConfig.X` and hands the result to the layout. Verify +// that App-level props win over the dashboard/global config, and fall back to it when absent. +vi.mock('../DesktopLayout', () => ({ DesktopLayout: vi.fn(() => null) })); +vi.mock('../MobileLayout', () => ({ MobileLayout: vi.fn(() => null) })); +vi.mock('../../../lib/MediaQueryContext', () => ({ useMediaQueryContext: vi.fn(() => ({ isMobile: false })) })); +vi.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); + +const setGlobalConfig = (replyType: string, enableReactions: boolean, enableMessageSearch: boolean) => { + vi.mocked(useSendbird).mockReturnValue({ + state: { + config: { + groupChannel: { replyType, enableReactions }, + groupChannelSettings: { enableMessageSearch }, + }, + }, + } as any); +}; + +const renderLayout = (extra: Record) => render( + , +); + +const lastDesktopProps = () => { + const calls = vi.mocked(DesktopLayout).mock.calls; + return calls[calls.length - 1][0]; +}; + +describe('AppLayout — config precedence (App props over dashboard config)', () => { + it('uses App-level props when provided (win over dashboard config)', () => { + setGlobalConfig('NONE', true, true); + renderLayout({ replyType: 'QUOTE_REPLY', isReactionEnabled: false, showSearchIcon: false }); + + expect(lastDesktopProps()).toEqual(expect.objectContaining({ + replyType: 'QUOTE_REPLY', + isReactionEnabled: false, // a `false` prop must NOT be overridden by the truthy config + showSearchIcon: false, + })); + }); + + it('falls back to the dashboard config when App props are absent', () => { + setGlobalConfig('THREAD', true, true); + renderLayout({}); + + expect(lastDesktopProps()).toEqual(expect.objectContaining({ + replyType: 'THREAD', // resolved from config via getCaseResolvedReplyType + isReactionEnabled: true, + showSearchIcon: true, + })); + }); +}); diff --git a/src/modules/Channel/components/ChannelUI/__tests__/ChannelUI.renderProps.integration.spec.tsx b/src/modules/Channel/components/ChannelUI/__tests__/ChannelUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..35e12e5fe0 --- /dev/null +++ b/src/modules/Channel/components/ChannelUI/__tests__/ChannelUI.renderProps.integration.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ChannelUI from '../index'; +import { useChannelContext } from '../../../context/ChannelProvider'; +import { GroupChannelUIView } from '../../../../GroupChannel/components/GroupChannelUI/GroupChannelUIView'; + +// ChannelUI (legacy) reads the channel context and forwards the customer's render props to the +// shared GroupChannelUIView, injecting default header/list/input renderers when absent. Mirrors +// GroupChannelUI.renderProps.integration.spec.tsx. +vi.mock('../../../context/ChannelProvider', () => ({ useChannelContext: vi.fn() })); +vi.mock('../../../../GroupChannel/components/GroupChannelUI/GroupChannelUIView', () => ({ GroupChannelUIView: vi.fn(() => null) })); +vi.mock('../../ChannelHeader', () => ({ __esModule: true, default: () => null })); +vi.mock('../../MessageList', () => ({ __esModule: true, default: () => null })); +vi.mock('../../MessageInputWrapper', () => ({ __esModule: true, default: () => null })); + +const viewProps = () => { + const calls = vi.mocked(GroupChannelUIView).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('ChannelUI (legacy) — render-prop injection/forwarding (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useChannelContext).mockReturnValue({ channelUrl: 'ch-1', isInvalid: false } as any); + }); + + it('forwards a customer renderChannelHeader to the view unchanged', () => { + const renderChannelHeader = vi.fn(() =>
); + + render(); + + expect(viewProps().renderChannelHeader).toBe(renderChannelHeader); + }); + + it('injects default header/list/input renderers when none are provided', () => { + render(); + + const props = viewProps(); + expect(typeof props.renderChannelHeader).toBe('function'); + expect(typeof props.renderMessageList).toBe('function'); + expect(typeof props.renderMessageInput).toBe('function'); + // each injected default is a real renderer (produces an element) + expect(props.renderChannelHeader({})).toBeTruthy(); + expect(props.renderMessageList({})).toBeTruthy(); + expect(props.renderMessageInput()).toBeTruthy(); + }); +}); diff --git a/src/modules/ChannelList/components/ChannelListUI/__tests__/ChannelListUI.renderProps.integration.spec.tsx b/src/modules/ChannelList/components/ChannelListUI/__tests__/ChannelListUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..83c7ba460c --- /dev/null +++ b/src/modules/ChannelList/components/ChannelListUI/__tests__/ChannelListUI.renderProps.integration.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ChannelListUI from '../index'; +import { useChannelListContext } from '../../../context/ChannelListProvider'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import type { Mock } from 'vitest'; + +// Legacy ChannelList. Verify the customer's render props reach the list: renderChannelPreview is +// invoked per channel with the channel item, and renderHeader is invoked. Mirrors +// GroupChannelListUI.renderProps.integration.spec.tsx. +const mockState = { + stores: { + userStore: { user: { userId: 'test-user-id' } }, + sdkStore: { sdk: { currentUser: { userId: 'test-user-id' }, isCacheEnabled: false }, initialized: true }, + }, + config: { logger: console, userId: 'test-user-id', isOnline: true }, +}; +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: vi.fn(() => ({ state: mockState })), + useSendbird: vi.fn(() => ({ state: mockState })), +})); +vi.mock('../../../context/ChannelListProvider', () => ({ useChannelListContext: vi.fn() })); + +const mockStringSet = { PLACE_HOLDER__NO_CHANNEL: 'No channels' }; + +const defaultContext = { + onThemeChange: undefined, + allowProfileEdit: false, + allChannels: [], + currentChannel: null, + channelListDispatcher: vi.fn(), + typingChannels: [], + initialized: false, + fetchChannelList: vi.fn(), + onProfileEditSuccess: undefined, +}; + +const renderComponent = (context: Record = {}, uiProps: Record = {}) => { + (useChannelListContext as Mock).mockReturnValue({ ...defaultContext, ...context }); + return render( + + + , + ); +}; + +describe('ChannelListUI (legacy) — render-prop propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('invokes a custom renderChannelPreview for each channel with the channel item', () => { + const channels = [{ url: 'url-1' }, { url: 'url-2' }]; + const renderChannelPreview = vi.fn(() =>
); + + renderComponent({ allChannels: channels, initialized: true }, { renderChannelPreview }); + + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[0] })); + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[1] })); + }); + + it('invokes a custom renderHeader', () => { + const renderHeader = vi.fn(() =>
); + + const { getByTestId } = renderComponent({ initialized: true }, { renderHeader }); + + expect(renderHeader).toHaveBeenCalled(); + // the custom header actually renders in place of the default + expect(getByTestId('custom-header')).toBeInTheDocument(); + }); +}); diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.spec.tsx similarity index 100% rename from src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.test.tsx rename to src/modules/ChannelSettings/__test__/ChannelSettingsUI.integration.spec.tsx diff --git a/src/modules/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..ccf1a41a96 --- /dev/null +++ b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ChannelSettingsUI from '../components/ChannelSettingsUI'; +import useChannelSettings from '../context/useChannelSettings'; +import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import { useLocalization } from '../../../lib/LocalizationContext'; +import useMenuItems from '../components/ChannelSettingsUI/hooks/useMenuItems'; + +// The existing OperatorList test covers one leaf render prop. This covers the settings panel's own +// render props: renderHeader / renderChannelProfile / renderModerationPanel / renderLeaveChannel +// are invoked with the expected args when the customer supplies them. +vi.mock('../context/useChannelSettings', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../../lib/LocalizationContext', async () => ({ + ...(await vi.importActual('../../../lib/LocalizationContext')), + useLocalization: vi.fn(), +})); +vi.mock('../components/ChannelSettingsUI/hooks/useMenuItems', () => ({ __esModule: true, default: vi.fn(() => []) })); + +const channel = { url: 'ch-1' }; + +describe('ChannelSettingsUI — render-prop propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSendbird).mockReturnValue({ state: { config: { isOnline: true } } } as any); + vi.mocked(useChannelSettings).mockReturnValue({ + state: { channel, invalidChannel: false, onCloseClick: vi.fn(), loading: false }, + } as any); + vi.mocked(useLocalization).mockReturnValue({ stringSet: { CHANNEL_SETTING__LEAVE_CHANNEL__TITLE: 'Leave' } } as any); + // Distinctive value so the assertion proves the menuItems came from useMenuItems() (not any defined value). + vi.mocked(useMenuItems).mockReturnValue([{ id: 'menu-x' }] as any); + }); + + it('invokes each customer render prop with the expected args', () => { + const renderHeader = vi.fn(() =>
); + const renderChannelProfile = vi.fn(() =>
); + const renderModerationPanel = vi.fn(() =>
); + const renderLeaveChannel = vi.fn(() =>
); // provided so the default leave menu is not rendered + + render( + , + ); + + // renderHeader is invoked with the header props ({ onCloseClick }) + expect(renderHeader).toHaveBeenCalledWith(expect.objectContaining({ onCloseClick: expect.any(Function) })); + expect(renderChannelProfile).toHaveBeenCalled(); + // renderModerationPanel is invoked with the exact menuItems computed by useMenuItems() + expect(renderModerationPanel).toHaveBeenCalledWith(expect.objectContaining({ menuItems: [{ id: 'menu-x' }] })); + expect(renderLeaveChannel).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx b/src/modules/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx new file mode 100644 index 0000000000..0b10c4d8c6 --- /dev/null +++ b/src/modules/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { OperatorList } from '../components/ModerationPanel/OperatorList'; +import useChannelSettings from '../context/useChannelSettings'; +import { useLocalization } from '../../../lib/LocalizationContext'; + +// Existing coverage (ChannelSettings.migration.spec.tsx) proves the renderUserListItem prop +// reaches the ChannelSettings context. This proves the other half: a leaf consumer actually +// INVOKES the customer's renderUserListItem with the expected user/channel args. +vi.mock('../context/useChannelSettings', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../../lib/LocalizationContext', async () => ({ + ...(await vi.importActual('../../../lib/LocalizationContext')), + useLocalization: vi.fn(), +})); + +const operator = { userId: 'op-1', nickname: 'Operator One' }; +const channel = { + url: 'channel-1', + createOperatorListQuery: vi.fn(() => ({ + next: vi.fn().mockResolvedValue([operator]), + hasNext: false, + })), +}; + +describe('OperatorList — renderUserListItem propagation (integration)', () => { + beforeEach(() => { + vi.mocked(useChannelSettings).mockReturnValue({ state: { channel } } as any); + vi.mocked(useLocalization).mockReturnValue({ + stringSet: { CHANNEL_SETTING__OPERATORS__TITLE_ADD: 'Add', CHANNEL_SETTING__OPERATORS__TITLE_ALL: 'All' }, + } as any); + }); + + it('invokes a custom renderUserListItem with the operator and channel', async () => { + const renderUserListItem = vi.fn(() => null); + render(); + + await waitFor(() => expect(renderUserListItem).toHaveBeenCalled()); + expect(renderUserListItem).toHaveBeenCalledWith( + expect.objectContaining({ user: operator, channel, size: 'small', avatarSize: '24px' }), + ); + }); +}); diff --git a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.spec.tsx similarity index 100% rename from src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.test.tsx rename to src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.integration.spec.tsx diff --git a/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.renderProps.integration.spec.tsx b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..a6d04c70a9 --- /dev/null +++ b/src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.renderProps.integration.spec.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CreateChannel from '../index'; +import useCreateChannel from '../../../context/useCreateChannel'; +import SelectChannelType from '../../SelectChannelType'; + +// CreateChannelUI shows step one (SelectChannelType) or step two (InviteUsers) from context state. +// Verify the customer's renderStepOne replaces the default, and onCancel is forwarded to the default. +vi.mock('../../../context/useCreateChannel', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../SelectChannelType', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../InviteUsers', () => ({ __esModule: true, default: vi.fn(() => null) })); + +const setStep = (pageStep: number) => { + vi.mocked(useCreateChannel).mockReturnValue({ + state: { pageStep, userListQuery: undefined }, + actions: { setPageStep: vi.fn() }, + } as any); +}; + +describe('CreateChannelUI — render-prop / callback propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('invokes a custom renderStepOne on step one (over the default SelectChannelType)', () => { + setStep(0); + const renderStepOne = vi.fn(() =>
); + + render(); + + expect(renderStepOne).toHaveBeenCalled(); + expect(vi.mocked(SelectChannelType)).not.toHaveBeenCalled(); + }); + + it('forwards onCancel to the default SelectChannelType', () => { + setStep(0); + const onCancel = vi.fn(); + + render(); + + const calls = vi.mocked(SelectChannelType).mock.calls; + expect((calls[calls.length - 1][0] as any).onCancel).toBe(onCancel); + }); +}); diff --git a/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 0000000000..948e69deed --- /dev/null +++ b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { renderHook, waitFor } from '@testing-library/react'; +import { CreateChannelProvider } from '../CreateChannelProvider'; +import useCreateChannel from '../useCreateChannel'; + +// Verify the customer's create-channel callbacks passed to CreateChannelProvider reach the store +// state (prop -> store -> context) unchanged. Mirrors the callback-propagation pattern. +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: vi.fn(() => ({ + state: { + stores: { sdkStore: { sdk: { currentUser: { userId: 'test-user-id' } }, initialized: true } }, + config: { logger: console }, + }, + })), +})); + +const onBeforeCreateChannel = vi.fn((users) => ({ invitedUserIds: users })); +const onCreateChannelClick = vi.fn(); + +describe('CreateChannelProvider — callback propagation (integration)', () => { + it('exposes the customer onBeforeCreateChannel / onCreateChannelClick on the state unchanged', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateChannel(), { wrapper }); + + // `toBe` (reference identity) proves the provider seeds the store with the exact functions + // (the state InviteUsers reads at create time) — not a wrapper/clone. + await waitFor(() => { + expect(result.current.state.onBeforeCreateChannel).toBe(onBeforeCreateChannel); + expect(result.current.state.onCreateChannelClick).toBe(onCreateChannelClick); + }); + }); +}); diff --git a/src/modules/CreateOpenChannel/components/CreateOpenChannelUI/__tests__/CreateOpenChannelUI.renderProps.integration.spec.tsx b/src/modules/CreateOpenChannel/components/CreateOpenChannelUI/__tests__/CreateOpenChannelUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..0739c7d006 --- /dev/null +++ b/src/modules/CreateOpenChannel/components/CreateOpenChannelUI/__tests__/CreateOpenChannelUI.renderProps.integration.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CreateOpenChannelUI from '../index'; +import { useCreateOpenChannelContext } from '../../../context/CreateOpenChannelProvider'; +import Modal from '../../../../../ui/Modal'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// CreateOpenChannelUI renders a Modal with either the customer's renderProfileInput or a default +// form, and forwards closeModal/renderHeader to the Modal. Verify those props propagate. +vi.mock('../../../context/CreateOpenChannelProvider', () => ({ useCreateOpenChannelContext: vi.fn() })); +// Modal renders its children so the profile-input branch mounts; it also captures the props it gets. +vi.mock('../../../../../ui/Modal', () => ({ __esModule: true, default: vi.fn(({ children }: any) => children) })); + +const stringSet = { + CREATE_OPEN_CHANNEL_LIST__TITLE: 'Create', + CREATE_OPEN_CHANNEL_LIST__SUBMIT: 'Create', + CREATE_OPEN_CHANNEL_LIST__SUBTITLE__IMG_SECTION: 'Image', + CREATE_OPEN_CHANNEL_LIST__SUBTITLE__IMG_UPLOAD: 'Upload', + CREATE_OPEN_CHANNEL_LIST__SUBTITLE__TEXT_SECTION: 'Name', + CREATE_OPEN_CHANNEL_LIST__SUBTITLE__TEXT_PLACE_HOLDER: 'Name', +} as any; + +const lastModalProps = () => { + const calls = vi.mocked(Modal).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +const renderUI = (uiProps: Record = {}) => render( + + + , +); + +describe('CreateOpenChannelUI — render-prop / callback propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCreateOpenChannelContext).mockReturnValue({ logger: console, createNewOpenChannel: vi.fn() } as any); + }); + + it('invokes a custom renderProfileInput (over the default form)', () => { + const renderProfileInput = vi.fn(() =>
); + + const { getByTestId } = renderUI({ renderProfileInput }); + + expect(renderProfileInput).toHaveBeenCalled(); + // the custom profile input actually renders in place of the default form + expect(getByTestId('custom-profile')).toBeInTheDocument(); + }); + + it('forwards closeModal and renderHeader to the modal', () => { + const closeModal = vi.fn(); + const renderHeader = vi.fn(() =>
); + + renderUI({ closeModal, renderHeader }); + + expect(lastModalProps().onCancel).toBe(closeModal); + expect(lastModalProps().renderHeader).toBe(renderHeader); + }); +}); diff --git a/src/modules/CreateOpenChannel/context/__tests__/CreateOpenChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/CreateOpenChannel/context/__tests__/CreateOpenChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 0000000000..3b73e7d35b --- /dev/null +++ b/src/modules/CreateOpenChannel/context/__tests__/CreateOpenChannelProvider.callbackPropagation.integration.spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { CreateOpenChannelProvider, useCreateOpenChannelContext } from '../CreateOpenChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; + +// CreateOpenChannel does NOT expose its callbacks on the context (they are closed over inside +// createNewOpenChannel). So verify propagation by invoking the create flow: onBeforeCreateChannel +// must transform the params passed to sdk.openChannel.createChannel, and onCreateChannel must +// receive the created channel. +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); + +const mockCreatedChannel = { url: 'created-open-channel' }; + +describe('CreateOpenChannelProvider — callback propagation (integration)', () => { + it('invokes onBeforeCreateChannel and onCreateChannel through the create flow', async () => { + const createChannel = vi.fn().mockResolvedValue(mockCreatedChannel); + vi.mocked(useSendbird).mockReturnValue({ + state: { + stores: { sdkStore: { sdk: { openChannel: { createChannel }, currentUser: { userId: 'me' } }, initialized: true } }, + config: { logger: console }, + }, + } as any); + + const onBeforeCreateChannel = vi.fn((params) => params); + const onCreateChannel = vi.fn(); + + const wrapper = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useCreateOpenChannelContext(), { wrapper }); + + await act(async () => { + result.current.createNewOpenChannel({ name: 'My Open Channel' }); + await Promise.resolve(); + }); + + // onBeforeCreateChannel receives (and can transform) the params handed to the SDK + expect(onBeforeCreateChannel).toHaveBeenCalledWith(expect.objectContaining({ name: 'My Open Channel' })); + expect(createChannel).toHaveBeenCalledWith(expect.objectContaining({ name: 'My Open Channel' })); + // onCreateChannel receives the created channel after the SDK resolves + await waitFor(() => expect(onCreateChannel).toHaveBeenCalledWith(mockCreatedChannel)); + }); +}); diff --git a/src/modules/EditUserProfile/components/EditUserProfileUI/__tests__/EditUserProfileUI.callbackPropagation.integration.spec.tsx b/src/modules/EditUserProfile/components/EditUserProfileUI/__tests__/EditUserProfileUI.callbackPropagation.integration.spec.tsx new file mode 100644 index 0000000000..005db1188c --- /dev/null +++ b/src/modules/EditUserProfile/components/EditUserProfileUI/__tests__/EditUserProfileUI.callbackPropagation.integration.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { EditUserProfileUI } from '../index'; +import { useEditUserProfileContext } from '../../../context/EditUserProfileProvider'; +import useSendbird from '../../../../../lib/Sendbird/context/hooks/useSendbird'; +import { EditUserProfileUIView } from '../EditUserProfileUIView'; +import Modal from '../../../../../ui/Modal'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// EditUserProfileUI reads its callbacks from context and hands them down: onThemeChange to the +// inner view, onCancel to the Modal. Verify those customer callbacks propagate unchanged. +vi.mock('../../../context/EditUserProfileProvider', () => ({ useEditUserProfileContext: vi.fn() })); +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../EditUserProfileUIView', () => ({ EditUserProfileUIView: vi.fn(() => null) })); +// Modal renders its children so the inner view mounts; also captures the onCancel it receives. +vi.mock('../../../../../ui/Modal', () => ({ __esModule: true, default: vi.fn(({ children }: any) => children) })); + +const stringSet = { EDIT_PROFILE__TITLE: 'Edit', BUTTON__SAVE: 'Save' } as any; + +const lastProps = (mockFn: any) => { + const calls = vi.mocked(mockFn).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('EditUserProfileUI — callback propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useSendbird).mockReturnValue({ + state: { stores: { sdkStore: { sdk: {} }, userStore: { user: {} } } }, + actions: { updateUserInfo: vi.fn() }, + } as any); + }); + + it('forwards onThemeChange to the profile view and onCancel to the modal', () => { + const onThemeChange = vi.fn(); + const onCancel = vi.fn(); + const onEditProfile = vi.fn(); + vi.mocked(useEditUserProfileContext).mockReturnValue({ onEditProfile, onCancel, onThemeChange } as any); + + render( + + + , + ); + + expect(lastProps(EditUserProfileUIView).onThemeChange).toBe(onThemeChange); + expect(lastProps(Modal).onCancel).toBe(onCancel); + }); +}); diff --git a/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx b/src/modules/GroupChannel/__test__/GroupChannelUIView.integration.spec.tsx similarity index 100% rename from src/modules/GroupChannel/__test__/GroupChannelUIView.integration.test.tsx rename to src/modules/GroupChannel/__test__/GroupChannelUIView.integration.spec.tsx diff --git a/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..b3c55077e0 --- /dev/null +++ b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { GroupChannelUI } from '../index'; +import { GroupChannelUIView } from '../GroupChannelUIView'; +import { useGroupChannelContext } from '../../../context/GroupChannelProvider'; +import { useGroupChannel } from '../../../context/hooks/useGroupChannel'; + +// The view's INVOCATION of these render props is already covered (GroupChannelUIView.integration.spec.tsx). +// This covers the other half: GroupChannelUI forwards a customer's render prop to the view, and injects +// a default renderer when none is provided. +vi.mock('../GroupChannelUIView', () => ({ GroupChannelUIView: vi.fn(() => null) })); +vi.mock('../../../context/GroupChannelProvider', () => ({ useGroupChannelContext: vi.fn(() => ({})) })); +vi.mock('../../../context/hooks/useGroupChannel', () => ({ useGroupChannel: vi.fn() })); + +const viewProps = () => { + const calls = vi.mocked(GroupChannelUIView).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('GroupChannelUI — render-prop injection/forwarding (integration)', () => { + beforeEach(() => { + vi.mocked(useGroupChannelContext).mockReturnValue({} as any); + vi.mocked(useGroupChannel).mockReturnValue({ state: { channelUrl: 'ch-1', fetchChannelError: null } } as any); + }); + + it('forwards customer render props to the view unchanged', () => { + const renderChannelHeader = vi.fn(() =>
); + const renderMessageInput = vi.fn(() =>
); + const renderMessageList = vi.fn(() =>
); + + render( + , + ); + + const props = viewProps(); + expect(props.renderChannelHeader).toBe(renderChannelHeader); + expect(props.renderMessageInput).toBe(renderMessageInput); + expect(props.renderMessageList).toBe(renderMessageList); + }); + + it('injects default renderers when none are provided', () => { + render(); + + const props = viewProps(); + expect(typeof props.renderChannelHeader).toBe('function'); + expect(typeof props.renderMessageList).toBe('function'); + expect(typeof props.renderMessageInput).toBe('function'); + // the injected default is a real renderer (produces an element) + expect(props.renderChannelHeader({})).toBeTruthy(); + }); +}); diff --git a/src/modules/GroupChannel/components/MessageList/__test__/MessageList.renderMessage.integration.spec.tsx b/src/modules/GroupChannel/components/MessageList/__test__/MessageList.renderMessage.integration.spec.tsx new file mode 100644 index 0000000000..ec3c6cdd61 --- /dev/null +++ b/src/modules/GroupChannel/components/MessageList/__test__/MessageList.renderMessage.integration.spec.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MessageList } from '../index'; +import Message from '../../Message'; +import { useGroupChannel } from '../../../context/hooks/useGroupChannel'; +import useSendbird from '../../../../../lib/Sendbird/context/hooks/useSendbird'; +import { useLocalization } from '../../../../../lib/LocalizationContext'; + +// Focus the test on MessageList's renderMessage wiring: stub the virtualized InfiniteList so it +// synchronously invokes renderMessage per message, mock the leaf Message to capture props, and +// inject controlled context. getMessagePartsInfo stays real (computes chainTop/hasSeparator/...). +vi.mock('../InfiniteList', () => ({ + InfiniteList: ({ messages, renderMessage }: any) => messages.map((message: any, index: number) => renderMessage({ message, index })), +})); +vi.mock('../../../../Message/context/MessageProvider', () => ({ + MessageProvider: ({ children }: any) => children, +})); +vi.mock('../../Message', () => ({ default: vi.fn(() => null) })); +vi.mock('../../../context/hooks/useGroupChannel', () => ({ useGroupChannel: vi.fn() })); +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ default: vi.fn() })); +vi.mock('../../../../../lib/LocalizationContext', async () => ({ + ...(await vi.importActual('../../../../../lib/LocalizationContext')), + useLocalization: vi.fn(), +})); + +const t0 = 1700000000000; +const t1 = 1700000060000; +const messages = [ + { messageId: 1, sendingStatus: 'succeeded', createdAt: t0, messageType: 'user', sender: { userId: 'user-1' }, isUserMessage: () => true, isFileMessage: () => false, isAdminMessage: () => false }, + { messageId: 2, sendingStatus: 'succeeded', createdAt: t1, messageType: 'user', sender: { userId: 'user-1' }, isUserMessage: () => true, isFileMessage: () => false, isAdminMessage: () => false }, +]; +const currentChannel = { + url: 'channel-1', + myLastRead: 0, + unreadMessageCount: 0, + isFrozen: false, + lastMessage: { createdAt: 0 }, + isGroupChannel: () => true, + getUnreadMemberCount: () => 0, + getUndeliveredMemberCount: () => 0, +}; +const groupChannelState = { + channelUrl: 'channel-1', + hasNext: () => false, + loading: false, + messages, + newMessages: [], + isScrollBottomReached: true, + isMessageGroupingEnabled: true, + currentChannel, + replyType: 'NONE', + scrollPubSub: { publish: vi.fn(), subscribe: vi.fn() }, + loadNext: vi.fn(), + loadPrevious: vi.fn(), + resetNewMessages: vi.fn(), + scrollRef: { current: null }, + scrollPositionRef: { current: 0 }, + scrollDistanceFromBottomRef: { current: 0 }, + markAsUnreadSourceRef: { current: null }, + readState: 'read', + autoscrollMessageOverflowToTop: false, +}; +const groupChannelActions = { + scrollToBottom: vi.fn(), + setIsScrollBottomReached: vi.fn(), + markAsReadAll: vi.fn(), + markAsUnread: vi.fn(), + scrollToMessage: vi.fn(), +}; +const sendbirdState = { + config: { + userId: 'user-1', + htmlTextDirection: 'ltr', + forceLeftToRightMessageLayout: false, + groupChannel: { enableMarkAsUnread: false, enableTypingIndicator: false, typingIndicatorTypes: undefined }, + }, + stores: { sdkStore: { sdk: {} } }, +}; + +describe('MessageList — renderMessage propagation (integration)', () => { + beforeEach(() => { + vi.mocked(useGroupChannel).mockReturnValue({ state: groupChannelState, actions: groupChannelActions } as any); + vi.mocked(useSendbird).mockReturnValue({ state: sendbirdState } as any); + vi.mocked(useLocalization).mockReturnValue({ stringSet: { DATE_FORMAT__MESSAGE_CREATED_AT: 'p' } } as any); + }); + + it('renders the default Message for each message with computed grouping props', () => { + render(); + + const calls = vi.mocked(Message).mock.calls; + const renderedMessages = calls.map((c) => (c[0] as any).message); + expect(renderedMessages).toContain(messages[0]); + expect(renderedMessages).toContain(messages[1]); + + const firstCall = calls.find((c) => (c[0] as any).message === messages[0]); + expect(firstCall?.[0]).toEqual(expect.objectContaining({ + message: messages[0], + chainTop: expect.any(Boolean), + chainBottom: expect.any(Boolean), + hasSeparator: expect.any(Boolean), + hasNewMessageSeparator: expect.any(Boolean), + })); + }); + + it('invokes a custom renderMessage prop with the full parameter bag', () => { + const renderMessage: any = vi.fn(() => null); + render(); + + expect(renderMessage).toHaveBeenCalled(); + const call = renderMessage.mock.calls.find((c) => (c[0] as any).message === messages[0]); + expect(call).toBeTruthy(); + expect((call![0] as any).message).toBe(messages[0]); + expect(Object.keys(call![0] as any)).toEqual(expect.arrayContaining([ + 'handleScroll', + 'message', + 'hasSeparator', + 'hasNewMessageSeparator', + 'chainTop', + 'chainBottom', + 'renderMessageContent', + 'renderSuggestedReplies', + 'renderCustomSeparator', + 'onNewMessageSeparatorVisibilityChange', + 'scrollMessageOverflowToTop', + ])); + + // custom renderMessage replaces the default leaf entirely + expect(vi.mocked(Message)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 0000000000..84e8f64f55 --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.callbackPropagation.integration.spec.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import { makeGroupChannelSendbirdState } from '../../../../utils/testMocks/groupChannelSendbirdState'; + +// Verify that the customer's message callbacks passed to GroupChannelProvider reach the context +// state (prop -> store -> context) unchanged. Their INVOCATION during a send is covered by +// useMessageActions.spec.tsx; this proves the provider wiring does not drop or replace them on +// the way to the send path. +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); + +const sendbirdState = makeGroupChannelSendbirdState(); + +// The full set of "before send / before update" hooks a customer can supply. +const callbacks = { + onBeforeSendUserMessage: vi.fn((p) => p), + onBeforeSendFileMessage: vi.fn((p) => p), + onBeforeSendVoiceMessage: vi.fn((p) => p), + onBeforeSendMultipleFilesMessage: vi.fn((p) => p), + onBeforeUpdateUserMessage: vi.fn((p) => p), +}; + +const renderContext = async () => { + vi.mocked(useSendbird).mockReturnValue(sendbirdState as any); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + let result: any; + await act(async () => { + result = renderHook(() => useGroupChannelContext(), { wrapper }).result; + }); + return result; +}; + +describe('GroupChannelProvider — message callback propagation (integration)', () => { + it('exposes every customer onBefore* callback on the context by the same reference', async () => { + const result = await renderContext(); + + // `toBe` (reference identity) proves the provider forwarded the exact function, not a wrapper/clone. + expect(result.current.onBeforeSendUserMessage).toBe(callbacks.onBeforeSendUserMessage); + expect(result.current.onBeforeSendFileMessage).toBe(callbacks.onBeforeSendFileMessage); + expect(result.current.onBeforeSendVoiceMessage).toBe(callbacks.onBeforeSendVoiceMessage); + expect(result.current.onBeforeSendMultipleFilesMessage).toBe(callbacks.onBeforeSendMultipleFilesMessage); + expect(result.current.onBeforeUpdateUserMessage).toBe(callbacks.onBeforeUpdateUserMessage); + }); +}); diff --git a/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx new file mode 100644 index 0000000000..8fc8207bd0 --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; +import { makeGroupChannelSendbirdState } from '../../../../utils/testMocks/groupChannelSendbirdState'; + +// Verify the precedence WIRING (module prop over dashboard config), not the pure resolver +// (already covered by resolvedReplyType.spec.ts). Mock useSendbird to inject the dashboard +// config.groupChannel.replyType; render the real GroupChannelProvider; read the resolved value. +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); + +const renderResolvedReplyType = async (dashboardReplyType: string, moduleReplyType?: string) => { + vi.mocked(useSendbird).mockReturnValue(makeGroupChannelSendbirdState({ replyType: dashboardReplyType }) as any); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + let result: any; + await act(async () => { + result = renderHook(() => useGroupChannelContext(), { wrapper }).result; + }); + return result.current.replyType; +}; + +describe('GroupChannelProvider — replyType precedence (module prop over dashboard config)', () => { + it('uses the module-level replyType prop when provided (wins over dashboard config)', async () => { + const replyType = await renderResolvedReplyType('NONE', 'QUOTE_REPLY'); + expect(replyType).toBe('QUOTE_REPLY'); + }); + + it('falls back to the dashboard config replyType when no module prop is given', async () => { + const replyType = await renderResolvedReplyType('THREAD'); + expect(replyType).toBe('THREAD'); + }); +}); diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.spec.tsx similarity index 100% rename from src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.test.tsx rename to src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.integration.spec.tsx diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..ac9ff97b97 --- /dev/null +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import GroupChannelListUI from '../index'; +import { useGroupChannelList as useGroupChannelListModule } from '../../../context/useGroupChannelList'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; +import type { Mock } from 'vitest'; + +// Verify the customer's render props reach the list: renderChannelPreview is invoked per channel +// with the channel item, and renderHeader is invoked. +const mockState = { + stores: { + userStore: { user: { userId: 'test-user-id' } }, + sdkStore: { sdk: { currentUser: { userId: 'test-user-id' } }, initialized: true }, + }, + config: { + logger: console, + userId: 'test-user-id', + groupChannel: { enableMention: true }, + isOnline: true, + }, +}; +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ + __esModule: true, + default: vi.fn(() => ({ state: mockState })), + useSendbird: vi.fn(() => ({ state: mockState })), +})); +vi.mock('../../../context/useGroupChannelList'); + +const mockStringSet = { PLACE_HOLDER__NO_CHANNEL: 'No channels' }; + +const defaultMockState = { + className: '', + selectedChannelUrl: '', + typingChannelUrls: [], + initialized: false, + groupChannels: [], + loadMore: null, + onChannelSelect: undefined, + onThemeChange: undefined, + onUserProfileUpdated: undefined, + allowProfileEdit: false, +}; + +const renderComponent = (state: Record = {}, uiProps: Record = {}) => { + (useGroupChannelListModule as Mock).mockReturnValue({ state: { ...defaultMockState, ...state } }); + return render( + + + , + ); +}; + +describe('GroupChannelListUI — render-prop propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('invokes a custom renderChannelPreview for each channel with the channel item', () => { + const channels = [ + { name: 'ch-1', url: 'url-1' }, + { name: 'ch-2', url: 'url-2' }, + ]; + const renderChannelPreview = vi.fn(() =>
); + + renderComponent({ groupChannels: channels, initialized: true }, { renderChannelPreview }); + + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[0], tabIndex: 0 })); + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[1] })); + }); + + it('invokes a custom renderHeader', () => { + const renderHeader = vi.fn(() =>
); + + const { getByTestId } = renderComponent({ initialized: true }, { renderHeader }); + + expect(renderHeader).toHaveBeenCalled(); + // the custom header actually renders in place of the default + expect(getByTestId('custom-header')).toBeInTheDocument(); + }); +}); diff --git a/src/modules/Message/context/__tests__/MessageProvider.contextPropagation.integration.spec.tsx b/src/modules/Message/context/__tests__/MessageProvider.contextPropagation.integration.spec.tsx new file mode 100644 index 0000000000..4a6951a279 --- /dev/null +++ b/src/modules/Message/context/__tests__/MessageProvider.contextPropagation.integration.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react'; +import { MessageProvider, useMessageContext } from '../MessageProvider'; + +// The Message module's public contract: MessageProvider exposes the given message + isByMe to +// consumers via useMessageContext, unchanged. +describe('MessageProvider — context propagation (integration)', () => { + it('exposes the provided message and isByMe on the context', () => { + const message = { messageId: 7 } as any; + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useMessageContext(), { wrapper }); + + expect(result.current.message).toBe(message); + expect(result.current.isByMe).toBe(true); + }); + + it('defaults isByMe to false when not provided', () => { + const message = { messageId: 8 } as any; + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useMessageContext(), { wrapper }); + + expect(result.current.message).toBe(message); + expect(result.current.isByMe).toBe(false); + }); +}); diff --git a/src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx b/src/modules/MessageSearch/__test__/MessageSearchUI.integration.spec.tsx similarity index 100% rename from src/modules/MessageSearch/__test__/MessageSearchUI.integration.test.tsx rename to src/modules/MessageSearch/__test__/MessageSearchUI.integration.spec.tsx diff --git a/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderItemKey.integration.spec.tsx b/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderItemKey.integration.spec.tsx new file mode 100644 index 0000000000..464183afeb --- /dev/null +++ b/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderItemKey.integration.spec.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MessageSearchUI } from '../index'; +import useMessageSearch from '../../../context/hooks/useMessageSearch'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// Regression guard for keying the custom renderSearchItem branch. Before the fix that branch +// returned the element with no key (the built-in item branches use key={message.messageId}), so +// React logged the "unique key" warning and risked mis-keyed list reconciliation. Rendering +// several custom items must emit no such warning — this fails on the pre-fix code. +vi.mock('../../../context/hooks/useMessageSearch', () => ({ __esModule: true, default: vi.fn() })); + +const stringSet = { NO_TITLE: 'No title', NO_NAME: 'No name' } as any; + +const baseState = { + isInvalid: false, + searchString: 'hello', + requestString: 'hello', + currentChannel: { name: 'ch', members: [] }, + loading: false, + scrollRef: { current: null }, + hasMoreResult: false, + onScroll: vi.fn(), + allMessages: [], + onResultClick: vi.fn(), + selectedMessageId: null, +}; + +const renderUI = (state: Record = {}, uiProps: Record = {}) => { + vi.mocked(useMessageSearch).mockReturnValue({ + state: { ...baseState, ...state }, + actions: { setSelectedMessageId: vi.fn(), handleRetryToConnect: vi.fn() }, + } as any); + return render( + + + , + ); +}; + +describe('MessageSearchUI — custom renderSearchItem keying (regression)', () => { + it('emits no React "unique key" warning when rendering multiple custom search items', () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const messages = [ + { messageId: 1, messageType: 'user' }, + { messageId: 2, messageType: 'user' }, + { messageId: 3, messageType: 'file' }, + ]; + // The customer's item carries no key of its own — the list must supply one. + const renderSearchItem = vi.fn(() =>
); + + renderUI({ allMessages: messages }, { renderSearchItem }); + + const keyWarning = consoleErrorSpy.mock.calls.find( + ([first]) => typeof first === 'string' && /each child in a list should have a unique/i.test(first), + ); + expect(keyWarning).toBeUndefined(); + + consoleErrorSpy.mockRestore(); + }); +}); diff --git a/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderProps.integration.spec.tsx b/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..f7c923f5d7 --- /dev/null +++ b/src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderProps.integration.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MessageSearchUI } from '../index'; +import useMessageSearch from '../../../context/hooks/useMessageSearch'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// Verify the customer's render props reach the search UI: renderSearchItem is invoked per result +// with the message, and the placeholder render props are used for their respective states. +vi.mock('../../../context/hooks/useMessageSearch', () => ({ __esModule: true, default: vi.fn() })); + +const stringSet = { NO_TITLE: 'No title', NO_NAME: 'No name' } as any; + +const baseState = { + isInvalid: false, + searchString: 'hello', + requestString: 'hello', + currentChannel: { name: 'ch', members: [] }, + loading: false, + scrollRef: { current: null }, + hasMoreResult: false, + onScroll: vi.fn(), + allMessages: [], + onResultClick: vi.fn(), + selectedMessageId: null, +}; + +const renderUI = (state: Record = {}, uiProps: Record = {}) => { + vi.mocked(useMessageSearch).mockReturnValue({ + state: { ...baseState, ...state }, + actions: { setSelectedMessageId: vi.fn(), handleRetryToConnect: vi.fn() }, + } as any); + return render( + + + , + ); +}; + +describe('MessageSearchUI — render-prop propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('invokes a custom renderSearchItem for each result with the message', () => { + const messages = [ + { messageId: 1, messageType: 'user' }, + { messageId: 2, messageType: 'file' }, + ]; + const renderSearchItem = vi.fn(() =>
); + + renderUI({ allMessages: messages }, { renderSearchItem }); + + expect(renderSearchItem).toHaveBeenCalledWith(expect.objectContaining({ message: messages[0] })); + expect(renderSearchItem).toHaveBeenCalledWith(expect.objectContaining({ message: messages[1] })); + }); + + it('invokes a custom renderPlaceHolderError on an invalid search', () => { + const renderPlaceHolderError = vi.fn(() =>
); + + renderUI({ isInvalid: true }, { renderPlaceHolderError }); + + expect(renderPlaceHolderError).toHaveBeenCalled(); + }); + + it('invokes a custom renderPlaceHolderEmptyList when the search returns no results', () => { + const renderPlaceHolderEmptyList = vi.fn(() =>
); + + renderUI({ allMessages: [] }, { renderPlaceHolderEmptyList }); + + expect(renderPlaceHolderEmptyList).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx index 4965911317..e8fb232056 100644 --- a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx +++ b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx @@ -125,7 +125,11 @@ export const MessageSearchUI: React.FC = ({ ? ( allMessages.map((message) => { if (renderSearchItem) { - return renderSearchItem({ message, onResultClick }); + return ( + + {renderSearchItem({ message, onResultClick })} + + ); } if (message.messageType === 'file') { return ( diff --git a/src/modules/OpenChannel/components/OpenChannelUI/__tests__/OpenChannelUI.renderProps.integration.spec.tsx b/src/modules/OpenChannel/components/OpenChannelUI/__tests__/OpenChannelUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..3b2e33b59f --- /dev/null +++ b/src/modules/OpenChannel/components/OpenChannelUI/__tests__/OpenChannelUI.renderProps.integration.spec.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import OpenChannelUI from '../index'; +import { useOpenChannelContext } from '../../../context/OpenChannelProvider'; +import OpenChannelMessageList from '../../OpenChannelMessageList'; +import OpenChannelHeader from '../../OpenChannelHeader'; +import OpenChannelInput from '../../OpenChannelInput'; + +// OpenChannelUI reads the open-channel context and either forwards the customer's render props to +// its children or injects the default header/list/input. Mock the context + children to capture +// forwarded props and detect default injection. +vi.mock('../../../context/OpenChannelProvider', () => ({ useOpenChannelContext: vi.fn() })); +vi.mock('../../OpenChannelMessageList', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../OpenChannelHeader', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../OpenChannelInput', () => ({ __esModule: true, default: vi.fn(() => null) })); + +const baseContext = { + currentOpenChannel: { url: 'open-1', isFrozen: false }, + amIBanned: false, + loading: false, + isInvalid: false, + messageInputRef: { current: null }, + conversationScrollRef: { current: null }, +}; + +const lastListProps = () => { + const calls = vi.mocked(OpenChannelMessageList).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('OpenChannelUI — render-prop injection/forwarding (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useOpenChannelContext).mockReturnValue(baseContext as any); + }); + + it('forwards a customer renderMessage to the message list and renderHeader over the default', () => { + const renderMessage = vi.fn(() =>
); + const renderHeader = vi.fn(() =>
); + + render(); + + // renderMessage reaches OpenChannelMessageList unchanged (same reference) + expect(lastListProps().renderMessage).toBe(renderMessage); + // custom header used; default OpenChannelHeader NOT rendered + expect(renderHeader).toHaveBeenCalled(); + expect(vi.mocked(OpenChannelHeader)).not.toHaveBeenCalled(); + }); + + it('injects the default header/input when no render props are provided', () => { + render(); + + expect(vi.mocked(OpenChannelHeader)).toHaveBeenCalled(); + expect(vi.mocked(OpenChannelInput)).toHaveBeenCalled(); + expect(lastListProps().renderMessage).toBeUndefined(); + }); +}); diff --git a/src/modules/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 0000000000..d3f994ccb3 --- /dev/null +++ b/src/modules/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { OpenChannelProvider, useOpenChannelContext } from '../OpenChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; + +// Verify that the customer's OpenChannel callbacks passed to OpenChannelProvider reach the context +// (prop -> context) unchanged. Mirrors GroupChannelProvider.callbackPropagation.integration.spec.tsx. +vi.mock('../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); + +const mockOpenChannel = { + url: 'test-channel', + enter: vi.fn().mockResolvedValue(undefined), + exit: vi.fn().mockResolvedValue(undefined), + isOperator: vi.fn(() => false), + getMyMutedInfo: vi.fn().mockResolvedValue({ isMuted: false }), + participantCount: 0, + createParticipantListQuery: vi.fn(() => ({ hasNext: false, next: vi.fn().mockResolvedValue([]) })), + createBannedUserListQuery: vi.fn(() => ({ hasNext: false, next: vi.fn().mockResolvedValue([]) })), + createMutedUserListQuery: vi.fn(() => ({ hasNext: false, next: vi.fn().mockResolvedValue([]) })), +}; + +const sendbirdState = { + state: { + stores: { + sdkStore: { + sdk: { + openChannel: { + getChannel: vi.fn().mockResolvedValue(mockOpenChannel), + addOpenChannelHandler: vi.fn(), + removeOpenChannelHandler: vi.fn(), + }, + currentUser: { userId: '1' }, + }, + initialized: true, + }, + userStore: { user: { userId: '1' } }, + }, + config: { + userId: '1', + isOnline: true, + logger: { info: vi.fn(), warning: vi.fn(), error: vi.fn() }, + pubSub: { subscribe: () => ({ remove: vi.fn() }) }, + imageCompression: {}, + }, + }, +}; + +const callbacks = { + onBeforeSendUserMessage: vi.fn((text) => ({ message: text })), + onBeforeSendFileMessage: vi.fn((file) => ({ file })), + onChatHeaderActionClick: vi.fn(), + onBackClick: vi.fn(), +}; + +const renderContext = async () => { + vi.mocked(useSendbird).mockReturnValue(sendbirdState as any); + // Empty channelUrl short-circuits useSetChannel (no channel load) — the callbacks come straight + // from props to the context value, so no channel is needed to verify their propagation. + const wrapper = ({ children }) => ( + + {children} + + ); + let result: any; + await act(async () => { + result = renderHook(() => useOpenChannelContext(), { wrapper }).result; + }); + return result; +}; + +describe('OpenChannelProvider — callback propagation (integration)', () => { + it('exposes every customer callback on the context by the same reference', async () => { + const result = await renderContext(); + + // `toBe` (reference identity) proves the provider forwarded the exact function, not a wrapper/clone. + expect(result.current.onBeforeSendUserMessage).toBe(callbacks.onBeforeSendUserMessage); + expect(result.current.onBeforeSendFileMessage).toBe(callbacks.onBeforeSendFileMessage); + expect(result.current.onChatHeaderActionClick).toBe(callbacks.onChatHeaderActionClick); + expect(result.current.onBackClick).toBe(callbacks.onBackClick); + }); +}); diff --git a/src/modules/OpenChannelApp/__tests__/OpenChannelApp.connectionProps.integration.spec.tsx b/src/modules/OpenChannelApp/__tests__/OpenChannelApp.connectionProps.integration.spec.tsx new file mode 100644 index 0000000000..e04511d6e7 --- /dev/null +++ b/src/modules/OpenChannelApp/__tests__/OpenChannelApp.connectionProps.integration.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import OpenChannelApp from '../index'; +import Sendbird from '../../../lib/Sendbird'; + +// OpenChannelApp is a thin wrapper that forwards the customer's connection props to +// (SendbirdProvider) and renders the open-channel layout as children. This proves the +// OpenChannelApp -> SendbirdProvider half of the chain; provider -> SDK is covered by +// SendbirdProvider.sdkInit.integration.spec.tsx. +vi.mock('../../../lib/Sendbird', () => ({ + __esModule: true, + default: vi.fn(() => null), // spy that captures props; returns null so the layout never mounts +})); + +const lastSendbirdProps = () => { + const calls = vi.mocked(Sendbird).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('OpenChannelApp — connection prop passthrough to SendbirdProvider (integration)', () => { + it('forwards the customer connection props to SendbirdProvider', () => { + const imageCompression = { compressionRate: 0.5 }; + + render( + , + ); + + expect(lastSendbirdProps()).toEqual(expect.objectContaining({ + appId: 'test-app-id', + userId: 'user-42', + nickname: 'Alice', + theme: 'dark', + imageCompression, + })); + }); +}); diff --git a/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx b/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..13584e97de --- /dev/null +++ b/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import OpenChannelListUI from '../index'; +import { useOpenChannelListContext } from '../../../context/OpenChannelListProvider'; +import { OpenChannelListFetchingStatus } from '../../../context/OpenChannelListInterfaces'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// Verify the customer's render props reach the open-channel list: renderChannelPreview is invoked +// per channel with the channel item, and renderHeader replaces the default header. Mirrors +// GroupChannelListUI.renderProps.integration.spec.tsx. +vi.mock('../../../context/OpenChannelListProvider', () => ({ useOpenChannelListContext: vi.fn() })); +// Heavy leaves that are never under test here — stub them so a bare render is cheap/safe. +vi.mock('../../OpenChannelPreview', () => ({ __esModule: true, default: () =>
})); +vi.mock('../../../../CreateOpenChannel', () => ({ __esModule: true, default: () => null })); + +const stringSet = { OPEN_CHANNEL_LIST__TITLE: 'Open channels' } as any; + +const baseState = { + logger: { info: vi.fn(), warning: vi.fn(), error: vi.fn() }, + currentChannel: null, + allChannels: [], + fetchingStatus: OpenChannelListFetchingStatus.DONE, + onChannelSelected: vi.fn(), + fetchNextChannels: vi.fn(), + refreshOpenChannelList: vi.fn(), + openChannelListDispatcher: vi.fn(), +}; + +const renderUI = (state: Record = {}, uiProps: Record = {}) => { + vi.mocked(useOpenChannelListContext).mockReturnValue({ ...baseState, ...state } as any); + return render( + + + , + ); +}; + +describe('OpenChannelListUI — render-prop propagation (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('invokes a custom renderChannelPreview for each channel with the channel item', () => { + const channels = [{ url: 'open-1' }, { url: 'open-2' }]; + const renderChannelPreview = vi.fn(() =>
); + + renderUI({ allChannels: channels, fetchingStatus: OpenChannelListFetchingStatus.DONE }, { renderChannelPreview }); + + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[0] })); + expect(renderChannelPreview).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[1] })); + }); + + it('re-invokes an updated renderChannelPreview even when the channel list reference is unchanged', () => { + const channels = [{ url: 'open-1' }]; + const first = vi.fn(() =>
); + const second = vi.fn(() =>
); + + const view = renderUI( + { allChannels: channels, fetchingStatus: OpenChannelListFetchingStatus.DONE }, + { renderChannelPreview: first }, + ); + // Re-render with a NEW render prop but the SAME allChannels reference. Regression guard for a + // stale MemoizedAllChannels useMemo that omitted renderChannelPreview from its deps. + view.rerender( + + + , + ); + + expect(second).toHaveBeenCalledWith(expect.objectContaining({ channel: channels[0] })); + }); + + it('invokes a custom renderHeader (replacing the default header)', () => { + const renderHeader = vi.fn(() =>
); + + const { getByTestId } = renderUI({}, { renderHeader }); + + expect(renderHeader).toHaveBeenCalled(); + // the custom header actually renders in place of the default + expect(getByTestId('custom-header')).toBeInTheDocument(); + }); + + it('invokes onChannelSelected with the clicked channel', () => { + const channels = [{ url: 'open-1' }]; + const onChannelSelected = vi.fn(); + const renderChannelPreview = vi.fn(() =>
); + + const { container } = renderUI( + { allChannels: channels, onChannelSelected, fetchingStatus: OpenChannelListFetchingStatus.DONE }, + { renderChannelPreview }, + ); + + // The wrapper around a custom preview carries the onClick that fires onChannelSelected. + const item = container.querySelector('.sendbird-open-channel-list-ui__channel-list__item'); + fireEvent.click(item as Element); + + expect(onChannelSelected).toHaveBeenCalledWith(expect.objectContaining({ url: 'open-1' }), expect.anything()); + }); +}); diff --git a/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx b/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx index 60b8646094..9198a5d742 100644 --- a/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx +++ b/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx @@ -123,6 +123,7 @@ function OpenChannelListUI({
{renderChannelPreview({ channel, isSelected, onChannelSelected })}
@@ -139,7 +140,16 @@ function OpenChannelListUI({ }); } return null; - }, [allChannels, allChannels.length, currentChannel]); + }, [ + allChannels, + allChannels.length, + currentChannel, + fetchingStatus, + renderChannelPreview, + onChannelSelected, + logger, + openChannelListDispatcher, + ]); return (
diff --git a/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/__tests__/OpenChannelSettingsUI.renderProps.integration.spec.tsx b/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/__tests__/OpenChannelSettingsUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..c21825b802 --- /dev/null +++ b/src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/__tests__/OpenChannelSettingsUI.renderProps.integration.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import OpenChannelSettingsUI from '../index'; +import { useOpenChannelSettingsContext } from '../../../context/OpenChannelSettingsProvider'; +import useSendbird from '../../../../../lib/Sendbird/context/hooks/useSendbird'; +import OperatorUI from '../../OperatorUI'; +import ParticipantUI from '../../ParticipantUI'; +import { LocalizationContext } from '../../../../../lib/LocalizationContext'; + +// OpenChannelSettingsUI branches on whether I'm an operator, then either forwards the customer's +// renderOperatorUI / renderParticipantList or injects the default OperatorUI / ParticipantUI. +vi.mock('../../../context/OpenChannelSettingsProvider', () => ({ useOpenChannelSettingsContext: vi.fn() })); +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../OperatorUI', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../ParticipantUI', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../InvalidChannel', () => ({ __esModule: true, default: vi.fn(() => null) })); + +const stringSet = { OPEN_CHANNEL_SETTINGS__PARTICIPANTS_TITLE: 'Participants' } as any; +const user = { userId: 'me' }; + +const setup = (isOperator: boolean) => { + vi.mocked(useOpenChannelSettingsContext).mockReturnValue({ + channel: { isOperator: vi.fn(() => isOperator) }, + onCloseClick: vi.fn(), + isChannelInitialized: true, + } as any); + vi.mocked(useSendbird).mockReturnValue({ + state: { config: { logger: console, theme: 'light' }, stores: { userStore: { user } } }, + } as any); +}; + +const renderUI = (uiProps: Record = {}) => render( + + + , +); + +describe('OpenChannelSettingsUI — render-prop injection/forwarding (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses a custom renderOperatorUI when I am an operator (over the default OperatorUI)', () => { + setup(true); + const renderOperatorUI = vi.fn(() =>
); + + renderUI({ renderOperatorUI }); + + expect(renderOperatorUI).toHaveBeenCalled(); + expect(vi.mocked(OperatorUI)).not.toHaveBeenCalled(); + }); + + it('uses a custom renderParticipantList when I am not an operator (over the default ParticipantUI)', () => { + setup(false); + const renderParticipantList = vi.fn(() =>
); + + renderUI({ renderParticipantList }); + + expect(renderParticipantList).toHaveBeenCalled(); + expect(vi.mocked(ParticipantUI)).not.toHaveBeenCalled(); + }); + + it('injects the default OperatorUI / ParticipantUI when no render props are provided', () => { + setup(true); + renderUI(); + expect(vi.mocked(OperatorUI)).toHaveBeenCalled(); + + setup(false); + renderUI(); + expect(vi.mocked(ParticipantUI)).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.spec.tsx similarity index 99% rename from src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx rename to src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.spec.tsx index 12795a4d46..3bc80ead8d 100644 --- a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.test.tsx +++ b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.integration.spec.tsx @@ -120,7 +120,7 @@ const defaultMockActions = { }), }; -describe('CreateChannelUI Integration Tests', () => { +describe('ThreadUI Integration Tests', () => { const mockUseThread = useThreadModule.default as Mock; const renderComponent = (mockState = {}, mockActions = {}) => { diff --git a/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.renderProps.integration.spec.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.renderProps.integration.spec.tsx new file mode 100644 index 0000000000..490e906787 --- /dev/null +++ b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.renderProps.integration.spec.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ThreadUI from '../index'; +import useThread from '../../../context/useThread'; +import useSendbird from '../../../../../lib/Sendbird/context/hooks/useSendbird'; +import { useLocalization } from '../../../../../lib/LocalizationContext'; +import ThreadList from '../../ThreadList'; +import ThreadMessageInput from '../../ThreadMessageInput'; + +// ThreadUI forwards the customer's render props to its children (ThreadList / ThreadMessageInput) +// or injects defaults. Mock the context hooks + children, and neutralize the memo hooks so the +// default children render deterministically (and getChannelTitle in the default header is skipped). +vi.mock('../../../context/useThread', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../../../../lib/Sendbird/context/hooks/useSendbird', () => ({ __esModule: true, default: vi.fn() })); +vi.mock('../../../../../lib/LocalizationContext', async () => ({ + ...(await vi.importActual('../../../../../lib/LocalizationContext')), + useLocalization: vi.fn(), +})); +vi.mock('../../ThreadList', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../ThreadMessageInput', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../ThreadHeader', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../ParentMessageInfo', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../../../../Message/context/MessageProvider', () => ({ MessageProvider: ({ children }: any) => children })); +vi.mock('../useMemorizedHeader', () => ({ __esModule: true, default: vi.fn(() => 'header') })); +vi.mock('../useMemorizedParentMessageInfo', () => ({ __esModule: true, default: vi.fn(() => null) })); +vi.mock('../useMemorizedThreadList', () => ({ __esModule: true, default: vi.fn(() => null) })); + +const threadState = { + currentChannel: { url: 'ch-1' }, + allThreadMessages: [], + parentMessage: { sender: { userId: 'user-1' } }, + parentMessageState: 'INITIALIZED', + threadListState: 'INITIALIZED', + hasMorePrev: false, + hasMoreNext: false, + onHeaderActionClick: vi.fn(), + onMoveToParentMessage: vi.fn(), +}; + +const lastThreadListProps = () => { + const calls = vi.mocked(ThreadList).mock.calls; + return calls[calls.length - 1][0] as any; +}; + +describe('ThreadUI — render-prop injection/forwarding (integration)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useThread).mockReturnValue({ + state: threadState, + actions: { fetchPrevThreads: vi.fn(), fetchNextThreads: vi.fn() }, + } as any); + vi.mocked(useSendbird).mockReturnValue({ + state: { stores: { sdkStore: { sdk: { currentUser: { userId: 'user-1' } } } }, config: {} }, + } as any); + vi.mocked(useLocalization).mockReturnValue({ + stringSet: { THREAD__THREAD_REPLIES: 'replies', THREAD__THREAD_REPLY: 'reply' }, + } as any); + }); + + it('forwards a customer renderMessage to the ThreadList and renderMessageInput over the default', () => { + const renderMessage = vi.fn(() =>
); + const renderMessageInput = vi.fn(() =>
); + + render(); + + expect(lastThreadListProps().renderMessage).toBe(renderMessage); + expect(renderMessageInput).toHaveBeenCalled(); + expect(vi.mocked(ThreadMessageInput)).not.toHaveBeenCalled(); + }); + + it('injects the default ThreadMessageInput when renderMessageInput is not provided', () => { + render(); + + expect(vi.mocked(ThreadMessageInput)).toHaveBeenCalled(); + expect(lastThreadListProps().renderMessage).toBeUndefined(); + }); +}); diff --git a/src/utils/testMocks/groupChannelSendbirdState.ts b/src/utils/testMocks/groupChannelSendbirdState.ts new file mode 100644 index 0000000000..4c8ea2229c --- /dev/null +++ b/src/utils/testMocks/groupChannelSendbirdState.ts @@ -0,0 +1,49 @@ +import { vi } from 'vitest'; + +/** + * Minimal `useSendbird()` return value for integration tests that mount a real GroupChannelProvider. + * + * Mirrors the connected-SDK shape the provider reads on mount: sdk.groupChannel.getChannel / + * createMessageCollection, and config.logger / markAsReadScheduler / groupChannel / pubSub. Each call + * returns fresh spies. Parameterize `replyType` for dashboard-config precedence tests. + */ +export function makeGroupChannelSendbirdState({ replyType = 'NONE' }: { replyType?: string } = {}) { + const mockChannel = { + url: 'test-channel', + members: [{ userId: '1', nickname: 'user1' }], + serialize: () => JSON.stringify({}), + }; + const mockMessageCollection = { + dispose: vi.fn(), + setMessageCollectionHandler: vi.fn(), + initialize: vi.fn().mockResolvedValue(null), + loadPrevious: vi.fn(), + loadNext: vi.fn(), + messages: [], + }; + return { + state: { + stores: { + sdkStore: { + sdk: { + groupChannel: { + getChannel: vi.fn().mockResolvedValue(mockChannel), + addGroupChannelHandler: vi.fn(), + removeGroupChannelHandler: vi.fn(), + }, + createMessageCollection: vi.fn().mockReturnValue(mockMessageCollection), + }, + initialized: true, + }, + }, + config: { + logger: { warning: vi.fn(), info: vi.fn(), error: vi.fn() }, + markAsReadScheduler: { push: vi.fn() }, + groupChannel: { replyType, threadReplySelectType: 'PARENT' }, + groupChannelSettings: { enableMessageSearch: true }, + isOnline: true, + pubSub: { subscribe: () => ({ remove: vi.fn() }) }, + }, + }, + }; +} diff --git a/src/utils/testMocks/renderWithSendbird.tsx b/src/utils/testMocks/renderWithSendbird.tsx new file mode 100644 index 0000000000..c6a608b7bf --- /dev/null +++ b/src/utils/testMocks/renderWithSendbird.tsx @@ -0,0 +1,25 @@ +import React, { ReactElement, ReactNode } from 'react'; +import { render, renderHook } from '@testing-library/react'; +import SendbirdProvider, { SendbirdProviderProps } from '../../lib/Sendbird'; + +const DEFAULT_PROPS = { + appId: 'test-app-id', + userId: 'test-user-id', +}; + +type Overrides = Partial; + +function makeWrapper(overrides: Overrides) { + return function SendbirdWrapper({ children }: { children?: ReactNode }) { + const props = { ...DEFAULT_PROPS, ...overrides } as SendbirdProviderProps; + return {children}; + }; +} + +export function renderWithSendbird(ui: ReactElement, overrides: Overrides = {}) { + return render(ui, { wrapper: makeWrapper(overrides) }); +} + +export function renderHookWithSendbird(callback: () => T, overrides: Overrides = {}) { + return renderHook(callback, { wrapper: makeWrapper(overrides) }); +} diff --git a/src/utils/testMocks/sendbirdChat.ts b/src/utils/testMocks/sendbirdChat.ts new file mode 100644 index 0000000000..fc20b72684 --- /dev/null +++ b/src/utils/testMocks/sendbirdChat.ts @@ -0,0 +1,63 @@ +import { vi } from 'vitest'; + +/** + * Shared '@sendbird/chat' mock for integration tests that mount a real SendbirdProvider. + * + * `vi.mock` is hoisted above imports and cannot close over module-scope variables, so load + * this via a dynamic import inside an async factory: + * + * vi.mock('@sendbird/chat', async () => ( + * await import('../../../utils/testMocks/sendbirdChat') + * ).createSendbirdChatMock()); + * import SendbirdChat from '@sendbird/chat'; + * // SendbirdChat === the mock sdk; SendbirdChat.init/connect/... are vi.fn() spies. + * + * Only '@sendbird/chat' is mocked. GroupChannelModule/OpenChannelModule come from + * '@sendbird/chat/groupChannel' and '/openChannel' (separate specifiers) and stay real, + * so `new GroupChannelModule()` in initSDK still works. + */ +export function createMockSdk() { + const mockSdk: Record = { + init: vi.fn(), + connect: vi.fn().mockResolvedValue({ + userId: 'test-user-id', + nickname: 'test-nickname', + profileUrl: 'test-profile-url', + }), + disconnect: vi.fn().mockResolvedValue(null), + disconnectWebSocket: vi.fn().mockResolvedValue(null), + updateCurrentUserInfo: vi.fn().mockResolvedValue(null), + addExtension: vi.fn().mockReturnThis(), + addSendbirdExtensions: vi.fn().mockReturnThis(), + setSessionHandler: vi.fn(), + getUIKitConfiguration: vi.fn().mockResolvedValue({ json: {} }), + GroupChannel: { createMyGroupChannelListQuery: vi.fn() }, + groupChannel: { createMyGroupChannelListQuery: vi.fn() }, + message: { + getMessageTemplatesByToken: vi.fn().mockResolvedValue({ hasMore: false, token: null, templates: [] }), + }, + appInfo: { + uploadSizeLimit: 1024 * 1024 * 5, + multipleFilesMessageFileCountLimit: 10, + // Fields the provider reads on connect(): dashboard config (uikit-tools) + message templates. + uikitConfigInfo: { lastUpdatedAt: 0 }, + messageTemplateInfo: { token: null }, + applicationAttributes: [], + }, + }; + // `SendbirdChat.init(...)` returns the same sdk, so init/connect/updateCurrentUserInfo + // spies are all reachable from the default import in tests. + mockSdk.init.mockImplementation(() => mockSdk); + return mockSdk; +} + +export function createSendbirdChatMock() { + const mockSdk = createMockSdk(); + return { + __esModule: true, + default: mockSdk, + SendbirdProduct: { UIKIT_CHAT: 'UIKIT_CHAT' }, + SendbirdPlatform: { JS: 'JS' }, + DeviceOsPlatform: { WEB: 'WEB', MOBILE_WEB: 'MOBILE_WEB' }, + }; +}