From 2b0c11354ea2aac6c30ab5ba6518d7cac1360a3e Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Wed, 1 Jul 2026 15:41:35 +0900 Subject: [PATCH 1/7] test: add integration tests for SDK init & prop propagation (CLNP-8676) Ticket 2 of the stabilization plan. Verify that customer-provided values reach the SDK and child components without being dropped or mutated, via integration / render-level tests. Test code only; no production changes. Shared test helpers (src/utils/testMocks): - sendbirdChat: reusable @sendbird/chat module mock with a faithful connected-SDK appInfo shape so connect() runs to completion. - renderWithSendbird: render/renderHook wrapped in a real SendbirdProvider. Coverage (21 tests / 8 files): - SendbirdProvider init end-to-end (mock only @sendbird/chat): init params incl. spread sdkInitParams, connect(userId, accessToken), updateCurrentUserInfo (+ not-called guard), setupSDK extensions/session handler, newInstance on first mount, onConnected, onFailed (error path). - MessageList renderMessage parameter bag (default + custom). - SendbirdProvider theme class toggle + stringSet merge (not replace). - GroupChannelProvider and AppLayout replyType/config precedence (module/App prop wins over dashboard config). - OperatorList renderUserListItem; GroupChannelList renderChannelPreview/ renderHeader; GroupChannelUI render-prop injection/forwarding. --- ...dbirdProvider.sdkInit.integration.spec.tsx | 157 ++++++++++++++++++ ...ovider.themeAndLocale.integration.spec.tsx | 97 +++++++++++ ...yout.configPrecedence.integration.spec.tsx | 56 +++++++ ...st.renderUserListItem.integration.spec.tsx | 42 +++++ ...ChannelUI.renderProps.integration.test.tsx | 55 ++++++ ...ageList.renderMessage.integration.spec.tsx | 130 +++++++++++++++ ...annelProvider.replyTypePrecedence.spec.tsx | 76 +++++++++ ...nelListUI.renderProps.integration.test.tsx | 78 +++++++++ src/utils/testMocks/renderWithSendbird.tsx | 25 +++ src/utils/testMocks/sendbirdChat.ts | 63 +++++++ 10 files changed, 779 insertions(+) create mode 100644 src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx create mode 100644 src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx create mode 100644 src/modules/App/__tests__/AppLayout.configPrecedence.integration.spec.tsx create mode 100644 src/modules/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx create mode 100644 src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.tsx create mode 100644 src/modules/GroupChannel/components/MessageList/__test__/MessageList.renderMessage.integration.spec.tsx create mode 100644 src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.spec.tsx create mode 100644 src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.test.tsx create mode 100644 src/utils/testMocks/renderWithSendbird.tsx create mode 100644 src/utils/testMocks/sendbirdChat.ts 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 000000000..f2a53402e --- /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 = console.error; + + beforeAll(() => { + // The connect() effect resolves async after mount; silence the RTL act warning noise. + console.error = vi.fn((...args) => { + if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return; + originalConsoleError(...args); + }); + }); + + afterAll(() => { + console.error = originalConsoleError; + }); + + 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 000000000..a40bc6dca --- /dev/null +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx @@ -0,0 +1,97 @@ +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 = console.error; + + beforeAll(() => { + console.error = vi.fn((...args) => { + if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return; + originalConsoleError(...args); + }); + }); + + afterAll(() => { + console.error = originalConsoleError; + }); + + 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]); + }); +}); 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 000000000..b178b567f --- /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/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx b/src/modules/ChannelSettings/__test__/OperatorList.renderUserListItem.integration.spec.tsx new file mode 100644 index 000000000..0b10c4d8c --- /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/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.tsx b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.tsx new file mode 100644 index 000000000..761f2dfce --- /dev/null +++ b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.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.test.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 000000000..ec3c6cdd6 --- /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.replyTypePrecedence.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.spec.tsx new file mode 100644 index 000000000..5898e9999 --- /dev/null +++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.spec.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { GroupChannelProvider, useGroupChannelContext } from '../GroupChannelProvider'; +import useSendbird from '../../../../lib/Sendbird/context/hooks/useSendbird'; + +// 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 mockLogger = { warning: vi.fn(), info: vi.fn(), error: vi.fn() }; +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: [], +}; + +const makeSendbirdState = (dashboardReplyType: string) => ({ + 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: mockLogger, + markAsReadScheduler: { push: vi.fn() }, + groupChannel: { replyType: dashboardReplyType, threadReplySelectType: 'PARENT' }, + groupChannelSettings: { enableMessageSearch: true }, + isOnline: true, + pubSub: { subscribe: () => ({ remove: vi.fn() }) }, + }, + }, +}); + +const renderResolvedReplyType = async (dashboardReplyType: string, moduleReplyType?: string) => { + vi.mocked(useSendbird).mockReturnValue(makeSendbirdState(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.renderProps.integration.test.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.test.tsx new file mode 100644 index 000000000..ac168e52b --- /dev/null +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.test.tsx @@ -0,0 +1,78 @@ +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(() =>
); + + renderComponent({ initialized: true }, { renderHeader }); + + expect(renderHeader).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/testMocks/renderWithSendbird.tsx b/src/utils/testMocks/renderWithSendbird.tsx new file mode 100644 index 000000000..c6a608b7b --- /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 000000000..fc20b7268 --- /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' }, + }; +} From 7bbe4c0c07620378da2acdf0c12a20c3ad6c435f Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 10 Jul 2026 16:34:53 +0900 Subject: [PATCH 2/7] test: expand prop/callback propagation integration coverage (CLNP-8676) Follow-up to the ticket-2 integration suite. Verify that customer-provided values reach the SDK and child components without being dropped or mutated, across the modules the first pass missed. Test code only. - App: connection/init props forwarded to SendbirdProvider (+ empty-string defaults). - GroupChannelProvider: onBeforeSend*/onBeforeUpdate* callbacks reach the context. - OpenChannel / OpenChannelSettings: render-prop forwarding + default injection. - Thread / MessageSearch / Channel (legacy) / CreateChannel / EditUserProfile: render-prop and callback propagation. - ChannelSettings: settings-panel render props (header/profile/moderation/leave). - SendbirdProvider: custom dateLocale reaches the localization context. - Extract makeGroupChannelSendbirdState() shared fixture (dedupe the callback and replyType provider tests). - Normalize all integration test filenames to *.integration.spec.tsx. --- ...ovider.themeAndLocale.integration.spec.tsx | 19 +++++ .../App.connectionProps.integration.spec.tsx | 69 +++++++++++++++++ ...ChannelUI.renderProps.integration.spec.tsx | 47 +++++++++++ ...ettingsUI.renderProps.integration.spec.tsx | 56 ++++++++++++++ ...ChannelUI.renderProps.integration.spec.tsx | 44 +++++++++++ ...I.callbackPropagation.integration.spec.tsx | 49 ++++++++++++ ...hannelUI.renderProps.integration.spec.tsx} | 0 ...r.callbackPropagation.integration.spec.tsx | 49 ++++++++++++ ....replyTypePrecedence.integration.spec.tsx} | 44 +---------- ...elListUI.renderProps.integration.spec.tsx} | 0 ...eSearchUI.renderProps.integration.spec.tsx | 72 +++++++++++++++++ ...ChannelUI.renderProps.integration.spec.tsx | 57 ++++++++++++++ ...ettingsUI.renderProps.integration.spec.tsx | 72 +++++++++++++++++ .../ThreadUI.renderProps.integration.spec.tsx | 77 +++++++++++++++++++ .../testMocks/groupChannelSendbirdState.ts | 49 ++++++++++++ 15 files changed, 662 insertions(+), 42 deletions(-) create mode 100644 src/modules/App/__tests__/App.connectionProps.integration.spec.tsx create mode 100644 src/modules/Channel/components/ChannelUI/__tests__/ChannelUI.renderProps.integration.spec.tsx create mode 100644 src/modules/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx create mode 100644 src/modules/CreateChannel/components/CreateChannelUI/__tests__/CreateChannelUI.renderProps.integration.spec.tsx create mode 100644 src/modules/EditUserProfile/components/EditUserProfileUI/__tests__/EditUserProfileUI.callbackPropagation.integration.spec.tsx rename src/modules/GroupChannel/components/GroupChannelUI/__test__/{GroupChannelUI.renderProps.integration.test.tsx => GroupChannelUI.renderProps.integration.spec.tsx} (100%) create mode 100644 src/modules/GroupChannel/context/__tests__/GroupChannelProvider.callbackPropagation.integration.spec.tsx rename src/modules/GroupChannel/context/__tests__/{GroupChannelProvider.replyTypePrecedence.spec.tsx => GroupChannelProvider.replyTypePrecedence.integration.spec.tsx} (56%) rename src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/{GroupChannelListUI.renderProps.integration.test.tsx => GroupChannelListUI.renderProps.integration.spec.tsx} (100%) create mode 100644 src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderProps.integration.spec.tsx create mode 100644 src/modules/OpenChannel/components/OpenChannelUI/__tests__/OpenChannelUI.renderProps.integration.spec.tsx create mode 100644 src/modules/OpenChannelSettings/components/OpenChannelSettingsUI/__tests__/OpenChannelSettingsUI.renderProps.integration.spec.tsx create mode 100644 src/modules/Thread/components/ThreadUI/__test__/ThreadUI.renderProps.integration.spec.tsx create mode 100644 src/utils/testMocks/groupChannelSendbirdState.ts diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx index a40bc6dca..4e23c1b51 100644 --- a/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx @@ -94,4 +94,23 @@ describe('SendbirdProvider — theme & stringSet propagation (integration)', () // 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 000000000..e84003771 --- /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/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 000000000..35e12e5fe --- /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/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx b/src/modules/ChannelSettings/__test__/ChannelSettingsUI.renderProps.integration.spec.tsx new file mode 100644 index 000000000..ccf1a41a9 --- /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/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 000000000..a6d04c70a --- /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/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 000000000..005db1188 --- /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/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.tsx b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx similarity index 100% rename from src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.test.tsx rename to src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx 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 000000000..84e8f64f5 --- /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.spec.tsx b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx similarity index 56% rename from src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.spec.tsx rename to src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx index 5898e9999..8fc8207bd 100644 --- a/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.spec.tsx +++ b/src/modules/GroupChannel/context/__tests__/GroupChannelProvider.replyTypePrecedence.integration.spec.tsx @@ -2,55 +2,15 @@ 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 mockLogger = { warning: vi.fn(), info: vi.fn(), error: vi.fn() }; -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: [], -}; - -const makeSendbirdState = (dashboardReplyType: string) => ({ - 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: mockLogger, - markAsReadScheduler: { push: vi.fn() }, - groupChannel: { replyType: dashboardReplyType, threadReplySelectType: 'PARENT' }, - groupChannelSettings: { enableMessageSearch: true }, - isOnline: true, - pubSub: { subscribe: () => ({ remove: vi.fn() }) }, - }, - }, -}); - const renderResolvedReplyType = async (dashboardReplyType: string, moduleReplyType?: string) => { - vi.mocked(useSendbird).mockReturnValue(makeSendbirdState(dashboardReplyType) as any); + vi.mocked(useSendbird).mockReturnValue(makeGroupChannelSendbirdState({ replyType: dashboardReplyType }) as any); const wrapper = ({ children }: { children?: React.ReactNode }) => ( {children} diff --git a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.test.tsx b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx similarity index 100% rename from src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.test.tsx rename to src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx 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 000000000..f7c923f5d --- /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/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 000000000..3b2e33b59 --- /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/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 000000000..c21825b80 --- /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.renderProps.integration.spec.tsx b/src/modules/Thread/components/ThreadUI/__test__/ThreadUI.renderProps.integration.spec.tsx new file mode 100644 index 000000000..490e90678 --- /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 000000000..4c8ea2229 --- /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() }) }, + }, + }, + }; +} From 0ddeddbd2b89528c93c778975682c643953b9dcd Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 10 Jul 2026 16:35:14 +0900 Subject: [PATCH 3/7] fix(OpenChannelList): recompute channel list when render props/callbacks change MemoizedAllChannels omitted renderChannelPreview, onChannelSelected, logger, openChannelListDispatcher (and fetchingStatus) from its useMemo deps, so a customer swapping renderChannelPreview or onChannelSelected while the channel list reference is unchanged kept stale previews and stale click callbacks. Add the missing deps, plus the missing list key on the custom-preview wrapper. Also adds OpenChannelListUI render-prop tests, including a rerender regression guard that fails without this fix. --- ...nelListUI.renderProps.integration.spec.tsx | 80 +++++++++++++++++++ .../components/OpenChannelListUI/index.tsx | 12 ++- 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx 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 000000000..442c5a9a9 --- /dev/null +++ b/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render } 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(() =>
); + + renderUI({}, { renderHeader }); + + expect(renderHeader).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx b/src/modules/OpenChannelList/components/OpenChannelListUI/index.tsx index 60b864609..9198a5d74 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 (
From 0789ea6c0c41a2119a3aaf1ce9b0cdf0dc6e1c61 Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 10 Jul 2026 17:02:06 +0900 Subject: [PATCH 4/7] test: add callback-propagation tests, cover untested modules, normalize filenames (CLNP-8676) Follow-up integration coverage. Test code only. New module coverage (previously had no integration test): - OpenChannelApp: connection props forwarded to SendbirdProvider. - CreateOpenChannel: renderProfileInput injection + closeModal/renderHeader forwarding. - ChannelList (legacy): renderChannelPreview per channel + renderHeader. - Message: MessageProvider exposes message/isByMe via useMessageContext. Callback propagation: - OpenChannelProvider: onBeforeSend*/onChatHeaderActionClick/onBackClick reach the context. - CreateChannelProvider: onBeforeCreateChannel/onCreateChannelClick reach the store state. - CreateOpenChannelProvider: the create flow invokes onBeforeCreateChannel + onCreateChannel. - OpenChannelListUI: clicking a channel invokes onChannelSelected with the channel. Housekeeping: - Rename the 6 pre-existing CLNP-4657 integration files from *.integration.test.tsx to *.integration.spec.tsx so every integration file follows one convention. - Fix a copy-paste describe title in ThreadUI.integration.spec.tsx (was 'CreateChannelUI'). - Fix a stale filename reference in a GroupChannelUI test comment. --- ...nelListUI.renderProps.integration.spec.tsx | 72 +++++++++++++++++ ...=> ChannelSettingsUI.integration.spec.tsx} | 0 ...x => CreateChannelUI.integration.spec.tsx} | 0 ...r.callbackPropagation.integration.spec.tsx | 42 ++++++++++ ...ChannelUI.renderProps.integration.spec.tsx | 59 ++++++++++++++ ...r.callbackPropagation.integration.spec.tsx | 46 +++++++++++ ...> GroupChannelUIView.integration.spec.tsx} | 0 ...ChannelUI.renderProps.integration.spec.tsx | 2 +- ...> GroupChannelListUI.integration.spec.tsx} | 0 ...nelListUI.renderProps.integration.spec.tsx | 4 +- ...er.contextPropagation.integration.spec.tsx | 31 +++++++ ...x => MessageSearchUI.integration.spec.tsx} | 0 ...r.callbackPropagation.integration.spec.tsx | 81 +++++++++++++++++++ ...elApp.connectionProps.integration.spec.tsx | 42 ++++++++++ ...nelListUI.renderProps.integration.spec.tsx | 23 +++++- ...test.tsx => ThreadUI.integration.spec.tsx} | 2 +- 16 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 src/modules/ChannelList/components/ChannelListUI/__tests__/ChannelListUI.renderProps.integration.spec.tsx rename src/modules/ChannelSettings/__test__/{ChannelSettingsUI.integration.test.tsx => ChannelSettingsUI.integration.spec.tsx} (100%) rename src/modules/CreateChannel/components/CreateChannelUI/__tests__/{CreateChannelUI.integration.test.tsx => CreateChannelUI.integration.spec.tsx} (100%) create mode 100644 src/modules/CreateChannel/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx create mode 100644 src/modules/CreateOpenChannel/components/CreateOpenChannelUI/__tests__/CreateOpenChannelUI.renderProps.integration.spec.tsx create mode 100644 src/modules/CreateOpenChannel/context/__tests__/CreateOpenChannelProvider.callbackPropagation.integration.spec.tsx rename src/modules/GroupChannel/__test__/{GroupChannelUIView.integration.test.tsx => GroupChannelUIView.integration.spec.tsx} (100%) rename src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/{GroupChannelListUI.integration.test.tsx => GroupChannelListUI.integration.spec.tsx} (100%) create mode 100644 src/modules/Message/context/__tests__/MessageProvider.contextPropagation.integration.spec.tsx rename src/modules/MessageSearch/__test__/{MessageSearchUI.integration.test.tsx => MessageSearchUI.integration.spec.tsx} (100%) create mode 100644 src/modules/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx create mode 100644 src/modules/OpenChannelApp/__tests__/OpenChannelApp.connectionProps.integration.spec.tsx rename src/modules/Thread/components/ThreadUI/__test__/{ThreadUI.integration.test.tsx => ThreadUI.integration.spec.tsx} (99%) 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 000000000..83c7ba460 --- /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/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/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/CreateChannel/context/__tests__/CreateChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 000000000..948e69dee --- /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 000000000..0739c7d00 --- /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 000000000..3b73e7d35 --- /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/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 index 761f2dfce..b3c55077e 100644 --- a/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx +++ b/src/modules/GroupChannel/components/GroupChannelUI/__test__/GroupChannelUI.renderProps.integration.spec.tsx @@ -5,7 +5,7 @@ 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.test.tsx). +// 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) })); 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 index ac168e52b..ac9ff97b9 100644 --- a/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx +++ b/src/modules/GroupChannelList/components/GroupChannelListUI/__tests__/GroupChannelListUI.renderProps.integration.spec.tsx @@ -71,8 +71,10 @@ describe('GroupChannelListUI — render-prop propagation (integration)', () => { it('invokes a custom renderHeader', () => { const renderHeader = vi.fn(() =>
); - renderComponent({ initialized: true }, { renderHeader }); + 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 000000000..4a6951a27 --- /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/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx b/src/modules/OpenChannel/context/__tests__/OpenChannelProvider.callbackPropagation.integration.spec.tsx new file mode 100644 index 000000000..d3f994ccb --- /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 000000000..e04511d6e --- /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 index 442c5a9a9..13584e97d 100644 --- a/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx +++ b/src/modules/OpenChannelList/components/OpenChannelListUI/__tests__/OpenChannelListUI.renderProps.integration.spec.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import OpenChannelListUI from '../index'; import { useOpenChannelListContext } from '../../../context/OpenChannelListProvider'; import { OpenChannelListFetchingStatus } from '../../../context/OpenChannelListInterfaces'; @@ -73,8 +73,27 @@ describe('OpenChannelListUI — render-prop propagation (integration)', () => { it('invokes a custom renderHeader (replacing the default header)', () => { const renderHeader = vi.fn(() =>
); - renderUI({}, { renderHeader }); + 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/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 12795a4d4..3bc80ead8 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 = {}) => { From 1f4a76978eb77957361c840fd9658fba62d9f898 Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 10 Jul 2026 17:54:56 +0900 Subject: [PATCH 5/7] fix(MessageSearch): key custom renderSearchItem items in the results list The custom renderSearchItem branch returned the element without a key while the default MessageSearchItem/FileItem branches use key={message.messageId}, so a customer-supplied renderSearchItem triggered React's "unique key" warning and risked mis-keyed list reconciliation. Wrap it in a keyed React.Fragment to match. --- .../MessageSearch/components/MessageSearchUI/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/MessageSearch/components/MessageSearchUI/index.tsx b/src/modules/MessageSearch/components/MessageSearchUI/index.tsx index 496591131..e8fb23205 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 ( From 896e18ed3a63e227d504d356416df127f3b6f376 Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 24 Jul 2026 11:57:22 +0900 Subject: [PATCH 6/7] test(MessageSearch): guard custom renderSearchItem keying (CLNP-8676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MessageSearchUI fix wraps a customer renderSearchItem result in a keyed React.Fragment; before it, that branch returned the element without a key while the built-in item branches use key={message.messageId}, so React logged the "unique key" warning and risked mis-keyed list reconciliation. Add a regression guard that renders several custom items and asserts no such warning is emitted — proven to fail on the pre-fix code and pass with the fix. --- ...earchUI.renderItemKey.integration.spec.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/modules/MessageSearch/components/MessageSearchUI/__tests__/MessageSearchUI.renderItemKey.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 000000000..464183afe --- /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(); + }); +}); From 3d2c412ea53f1919abf562905a76cfad96d9955b Mon Sep 17 00:00:00 2001 From: Tyler Jeong Date: Fri, 24 Jul 2026 12:16:51 +0900 Subject: [PATCH 7/7] test(Sendbird): use vi.spyOn for the act-warning console filter (CLNP-8676) The sdkInit and themeAndLocale integration specs silenced React's "not wrapped in act" warning by assigning console.error = vi.fn(), which tripped eslint's no-console rule (6 warnings). Switch to vi.spyOn(console, 'error') with the same selective pass-through (non-act errors still surface) and restore via vi.restoreAllMocks(). Behavior unchanged; lint is now clean. --- .../__tests__/SendbirdProvider.sdkInit.integration.spec.tsx | 6 +++--- .../SendbirdProvider.themeAndLocale.integration.spec.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx index f2a53402e..9d5d406f5 100644 --- a/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.sdkInit.integration.spec.tsx @@ -22,18 +22,18 @@ const mountProvider = async (props: Partial) => { }; describe('SendbirdProvider — SDK init/connect consistency (integration)', () => { - const originalConsoleError = console.error; + const originalConsoleError = globalThis.console.error.bind(globalThis.console); beforeAll(() => { // The connect() effect resolves async after mount; silence the RTL act warning noise. - console.error = vi.fn((...args) => { + vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return; originalConsoleError(...args); }); }); afterAll(() => { - console.error = originalConsoleError; + vi.restoreAllMocks(); }); beforeEach(() => { diff --git a/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx index 4e23c1b51..ca81c0764 100644 --- a/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx +++ b/src/lib/Sendbird/__tests__/SendbirdProvider.themeAndLocale.integration.spec.tsx @@ -24,17 +24,17 @@ const LocalizationProbe = () => { }; describe('SendbirdProvider — theme & stringSet propagation (integration)', () => { - const originalConsoleError = console.error; + const originalConsoleError = globalThis.console.error.bind(globalThis.console); beforeAll(() => { - console.error = vi.fn((...args) => { + vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return; originalConsoleError(...args); }); }); afterAll(() => { - console.error = originalConsoleError; + vi.restoreAllMocks(); }); beforeEach(() => {