From 6bdcf2be49fcc73d723059a262810ff2a5662148 Mon Sep 17 00:00:00 2001 From: Rajat Dabade Date: Tue, 1 Jul 2025 13:47:02 +0530 Subject: [PATCH] Remaining test for draft and Scheduled post (#8887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added test for draft_scheduled_post and header component * Added test for drafts_button/index.ts * Added test for send_button/index.ts * Added test for servers/scheduled_post.ts queries * Added test for global_scheduled_post_list/index.ts * Added test for rescheduled draft index file and minor update * Added test for core option and index * Added test for scheduled post options * Added test for send_draft index file * updated test for draft_scheduled_post and draft_scheduled_post_header * Updated test for drafts_button index * Updated test for send_button index * Updated test for server/scheduled_post * Updated test for global_scheduled_post/index * removed the unnecessary config and team data to populate in db for test * Update app/components/draft_scheduled_post/draft_scheduled_post.test.tsx Co-authored-by: Daniel Espino García * linter fixes --------- Co-authored-by: Mattermost Build Co-authored-by: Daniel Espino García --- .../draft_scheduled_post.test.tsx | 220 ++++++++++++++ .../draft_scheduled_post.tsx | 10 +- .../send_draft/index.test.tsx | 179 +++++++++++ .../draft_scheduled_post_header.test.tsx | 130 ++++++++ app/components/drafts_buttton/index.test.tsx | 99 ++++++ app/components/drafts_buttton/index.ts | 6 +- .../post_draft/send_button/index.test.tsx | 68 +++++ .../post_draft/send_button/index.tsx | 2 +- .../send_button/send_button.test.tsx | 2 +- .../post_draft/send_button/send_button.tsx | 8 +- app/queries/servers/scheduled_post.test.ts | 287 ++++++++++++++++++ app/queries/servers/scheduled_post.ts | 6 +- .../global_scheduled_post_list/index.test.tsx | 234 ++++++++++++++ app/screens/reschedule_draft/indext.test.tsx | 81 +++++ .../core_options/core_options.test.tsx | 204 +++++++++++++ .../core_options/index.test.tsx | 97 ++++++ .../scheduled_post_options/index.test.tsx | 78 +++++ test/test_helper.ts | 40 ++- 18 files changed, 1739 insertions(+), 12 deletions(-) create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post.test.tsx create mode 100644 app/components/draft_scheduled_post/draft_scheduled_post_actions/send_draft/index.test.tsx create mode 100644 app/components/draft_scheduled_post_header/draft_scheduled_post_header.test.tsx create mode 100644 app/components/drafts_buttton/index.test.tsx create mode 100644 app/components/post_draft/send_button/index.test.tsx create mode 100644 app/queries/servers/scheduled_post.test.ts create mode 100644 app/screens/global_drafts/components/global_scheduled_post_list/index.test.tsx create mode 100644 app/screens/reschedule_draft/indext.test.tsx create mode 100644 app/screens/scheduled_post_options/core_options/core_options.test.tsx create mode 100644 app/screens/scheduled_post_options/core_options/index.test.tsx create mode 100644 app/screens/scheduled_post_options/index.test.tsx diff --git a/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx new file mode 100644 index 000000000..76b7a04bc --- /dev/null +++ b/app/components/draft_scheduled_post/draft_scheduled_post.test.tsx @@ -0,0 +1,220 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen} from '@testing-library/react-native'; +import React from 'react'; + +import {switchToThread} from '@actions/local/thread'; +import {switchToChannelById} from '@actions/remote/channel'; +import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft'; +import {openAsBottomSheet} from '@screens/navigation'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import DraftAndScheduledPost from './draft_scheduled_post'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +jest.mock('@actions/local/thread', () => ({ + switchToThread: jest.fn(), +})); + +jest.mock('@actions/remote/channel', () => ({ + switchToChannelById: jest.fn(), +})); + +jest.mock('@screens/navigation', () => ({ + openAsBottomSheet: jest.fn(), +})); + +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn().mockReturnValue('https://server.com'), +})); + +jest.mock('@components/draft_scheduled_post_header', () => () => null); + +jest.mock('./draft_scheduled_post_container', () => () => null); + +describe('DraftAndScheduledPost', () => { + const baseProps: Parameters[0] = { + channel: TestHelper.fakeChannelModel({ + id: 'channel-id', + teamId: 'team-id', + }), + location: 'location' as AvailableScreens, + post: TestHelper.fakeDraftModel({ + rootId: '', + updateAt: 1234567890, + metadata: {}, + files: [] as FileInfo[], + }), + layoutWidth: 300, + isPostPriorityEnabled: false, + draftType: DRAFT_TYPE_DRAFT, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly for draft', () => { + renderWithIntlAndTheme(); + expect(screen.getByTestId('draft_post')).toBeTruthy(); + }); + + it('renders error line for scheduled post with error', () => { + const props = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + post: TestHelper.fakeScheduledPostModel({ + rootId: '', + updateAt: 1234567890, + metadata: {}, + files: [] as FileInfo[], + scheduledAt: 1234567890, + errorCode: 'some_error', + }), + }; + renderWithIntlAndTheme(); + + expect(screen.getByTestId('draft_post.error_line')).toBeTruthy(); + }); + + it('does not render error line for scheduled post without error', () => { + const props = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + post: TestHelper.fakeScheduledPostModel({ + rootId: '', + updateAt: 1234567890, + metadata: {}, + files: [] as FileInfo[], + scheduledAt: 1234567890, + errorCode: '', + }), + }; + renderWithIntlAndTheme(); + + expect(screen.queryByTestId('draft_post.error_line')).toBeNull(); + }); + + it('does not render error line for draft posts', () => { + renderWithIntlAndTheme(); + + expect(screen.queryByTestId('draft_post.error_line')).toBeNull(); + }); + + it('renders post priority when enabled and present', () => { + const props = { + ...baseProps, + isPostPriorityEnabled: true, + post: TestHelper.fakeDraftModel({ + rootId: '', + updateAt: 1234567890, + files: [] as FileInfo[], + metadata: { + priority: { + priority: 'important', + }, + }, + }), + }; + renderWithIntlAndTheme(); + + expect(screen.getByTestId('draft_post.priority')).toBeTruthy(); + }); + + it('does not render post priority when disabled', () => { + const props = { + ...baseProps, + isPostPriorityEnabled: false, + post: TestHelper.fakeDraftModel({ + rootId: '', + updateAt: 1234567890, + files: [] as FileInfo[], + metadata: { + priority: { + priority: 'important', + }, + }, + }), + }; + renderWithIntlAndTheme(); + + expect(screen.queryByTestId('draft_post.priority')).toBeNull(); + }); + + it('does not render post priority when not present in metadata', () => { + const props = { + ...baseProps, + isPostPriorityEnabled: true, + post: TestHelper.fakeDraftModel({ + rootId: '', + updateAt: 1234567890, + files: [] as FileInfo[], + metadata: {}, + }), + }; + renderWithIntlAndTheme(); + + expect(screen.queryByTestId('draft_post.priority')).toBeNull(); + }); + + it('navigates to thread when post has rootId', () => { + const props = { + ...baseProps, + post: TestHelper.fakeDraftModel({ + rootId: 'root-id', + updateAt: 1234567890, + metadata: {}, + files: [] as FileInfo[], + }), + }; + renderWithIntlAndTheme(); + + fireEvent.press(screen.getByTestId('draft_post')); + + expect(switchToThread).toHaveBeenCalledWith('https://server.com', 'root-id', false); + expect(switchToChannelById).not.toHaveBeenCalled(); + }); + + it('navigates to channel when post has no rootId', () => { + renderWithIntlAndTheme(); + + fireEvent.press(screen.getByTestId('draft_post')); + + expect(switchToChannelById).toHaveBeenCalledWith('https://server.com', 'channel-id', 'team-id', false); + expect(switchToThread).not.toHaveBeenCalled(); + }); + + it('opens options on long press for draft', () => { + const mockOpenAsBottonSheet = jest.mocked(openAsBottomSheet); + renderWithIntlAndTheme(); + + fireEvent(screen.getByTestId('draft_post'), 'longPress'); + + expect(mockOpenAsBottonSheet).toHaveBeenCalled(); + expect(mockOpenAsBottonSheet.mock.calls[0][0].props?.draftType).toBe(DRAFT_TYPE_DRAFT); + }); + + it('opens options on long press for scheduled post', () => { + const mockOpenAsBottonSheet = jest.mocked(openAsBottomSheet); + const props = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + post: TestHelper.fakeScheduledPostModel({ + rootId: '', + updateAt: 1234567890, + metadata: {}, + files: [] as FileInfo[], + scheduledAt: 1234567890, + }), + }; + renderWithIntlAndTheme(); + + fireEvent(screen.getByTestId('draft_post'), 'longPress'); + + expect(mockOpenAsBottonSheet).toHaveBeenCalled(); + expect(mockOpenAsBottonSheet.mock.calls[0][0].props?.draftType).toBe(DRAFT_TYPE_SCHEDULED); + }); +}); diff --git a/app/components/draft_scheduled_post/draft_scheduled_post.tsx b/app/components/draft_scheduled_post/draft_scheduled_post.tsx index a60fc8525..49de969b1 100644 --- a/app/components/draft_scheduled_post/draft_scheduled_post.tsx +++ b/app/components/draft_scheduled_post/draft_scheduled_post.tsx @@ -118,7 +118,10 @@ const DraftAndScheduledPost: React.FC = ({ > {draftType === DRAFT_TYPE_SCHEDULED && (post as ScheduledPostModel).errorCode !== '' && - + } = ({ scheduledPostErrorCode={'errorCode' in post ? post.errorCode : undefined} /> {showPostPriority && post.metadata?.priority && - +
({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(SendDraft).mockImplementation((props) => React.createElement('SendDraft', {...props, testID: 'send-draft'})); + +describe('EnhancedSendDraft', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + const defaultProps = { + channelId: 'channel1', + rootId: 'root1', + channelType: General.DM_CHANNEL, + currentUserId: 'user1', + channelName: 'channel1', + maxMessageLength: 1000, + customEmojis: [], + value: '', + files: [], + useChannelMentions: false, + userIsOutOfOffice: false, + persistentNotificationInterval: 0, + persistentNotificationMaxRecipients: 0, + postPriority: { + priority: PostPriorityType.STANDARD, + requested_ack: false, + persistent_notifications: false, + }, + bottomSheetId: Screens.SCHEDULED_POST_OPTIONS, + channelDisplayName: 'channel1', + draftReceiverUserName: '', + }; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + await operator.handleUsers({users: [TestHelper.fakeUser({ + id: 'user1', + })], + prepareRecordsOnly: false}); + await operator.handleTeam({teams: [TestHelper.fakeTeam({id: teamId})], prepareRecordsOnly: false}); + await operator.handleSystem( + { + systems: [ + { + id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, + value: teamId, + }, + { + id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, + value: 'user1', + }, + { + id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID, + value: 'channel1', + }, + ], + prepareRecordsOnly: false, + }); + await operator.handleConfigs({ + configs: [ + {id: 'Version', value: '7.6.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + await operator.handleMyTeam({ + myTeams: [ + { + id: teamId, + roles: 'role-name', + }, + ], + prepareRecordsOnly: false, + }); + + await operator.handleRole({roles: [TestHelper.fakeRole({ + id: 'role-id', + name: 'role-name', + permissions: [permissions.CREATE_POST], + })], + prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return the correct values', async () => { + await operator.handleChannel({channels: [TestHelper.fakeChannel({id: 'channel1', team_id: teamId})], prepareRecordsOnly: false}); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('send-draft')).toBeTruthy(); + expect(getByTestId('send-draft').props.canPost).toBe(true); + expect(getByTestId('send-draft').props.channelIsArchived).toBe(false); + expect(getByTestId('send-draft').props.channelIsReadOnly).toBe(false); + expect(getByTestId('send-draft').props.deactivatedChannel).toBe(false); + }); + + it('should return the correct values when the channel is archived', async () => { + await operator.handleChannel({channels: [TestHelper.fakeChannel({id: 'channel1', delete_at: 1})], prepareRecordsOnly: false}); + const {getByTestId} = renderWithEverything( + , + {database}, + + ); + + expect(getByTestId('send-draft')).toBeTruthy(); + expect(getByTestId('send-draft').props.channelIsArchived).toBe(true); + }); + + it('should return the correct values when the channel is read only', async () => { + await operator.handleChannel({channels: [TestHelper.fakeChannel({id: 'channel1', name: General.DEFAULT_CHANNEL, delete_at: 0})], prepareRecordsOnly: false}); + await operator.handleConfigs({ + configs: [ + {id: 'ExperimentalTownSquareIsReadOnly', value: 'true'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('send-draft')).toBeTruthy(); + expect(getByTestId('send-draft').props.channelIsReadOnly).toBe(true); + }); + + it('should return the correct values when the channel is deactivated', async () => { + await operator.handleChannel({channels: [TestHelper.fakeChannel({id: 'channel1', name: 'user2__channel1', type: General.DM_CHANNEL, delete_at: 0})], prepareRecordsOnly: false}); + await operator.handleUsers({ + users: [ + TestHelper.fakeUser({ + id: 'user2', + delete_at: 1, + }), + ], + prepareRecordsOnly: false, + }); + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('send-draft')).toBeTruthy(); + expect(getByTestId('send-draft').props.deactivatedChannel).toBe(true); + }); + +}); diff --git a/app/components/draft_scheduled_post_header/draft_scheduled_post_header.test.tsx b/app/components/draft_scheduled_post_header/draft_scheduled_post_header.test.tsx new file mode 100644 index 000000000..c3e333602 --- /dev/null +++ b/app/components/draft_scheduled_post_header/draft_scheduled_post_header.test.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react-native'; +import React from 'react'; + +import {General} from '@constants'; +import {DRAFT_TYPE_DRAFT, DRAFT_TYPE_SCHEDULED} from '@constants/draft'; +import {renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import DraftAndScheduledPostHeader from './draft_scheduled_post_header'; + +// Mock the datetime util to provide both getReadableTimestamp and toMilliseconds +jest.mock('@utils/datetime', () => ({ + getReadableTimestamp: jest.fn(() => 'May 5, 2025, 10:00 AM'), + toMilliseconds: jest.fn(({minutes}) => minutes * 60 * 1000), +})); + +jest.mock('@utils/user', () => ({ + getUserTimezone: jest.fn(() => ({ + useAutomaticTimezone: true, + automaticTimezone: 'America/New_York', + manualTimezone: '', + })), +})); + +jest.mock('@utils/scheduled_post', () => ({ + getErrorStringFromCode: jest.fn(() => 'Error message'), +})); + +jest.mock('@components/draft_scheduled_post_header/profile_avatar', () => { + const MockProfileAvatar = () => null; + return MockProfileAvatar; +}); + +describe('DraftAndScheduledPostHeader', () => { + const baseProps: Parameters[0] = { + channel: TestHelper.fakeChannelModel({ + id: 'channel-id', + type: General.OPEN_CHANNEL, + displayName: 'Test Channel', + }), + updateAt: 1620000000000, + draftType: DRAFT_TYPE_DRAFT, + testID: 'draft_header', + isMilitaryTime: false, + currentUser: TestHelper.fakeUserModel({ + id: 'user-id', + username: 'testuser', + locale: 'en', + }), + }; + + it('renders draft header correctly', () => { + renderWithIntlAndTheme(); + + expect(screen.getByText('In:')).toBeTruthy(); + expect(screen.getByText('Test Channel')).toBeTruthy(); + }); + + it('renders DM draft header correctly', () => { + const dmProps: Parameters[0] = { + ...baseProps, + channel: TestHelper.fakeChannelModel({ + id: 'channel-id', + type: General.DM_CHANNEL, + displayName: 'Test Channel', + }), + postReceiverUser: TestHelper.fakeUserModel({ + id: 'receiver-id', + username: 'receiver', + }), + }; + + renderWithIntlAndTheme(); + + expect(screen.getByText('To:')).toBeTruthy(); + }); + + it('renders thread draft header correctly', () => { + const threadProps = { + ...baseProps, + rootId: 'root-post-id', + }; + + renderWithIntlAndTheme(); + + expect(screen.getByText('Thread in:')).toBeTruthy(); + }); + + it('renders scheduled post header correctly', () => { + const scheduledProps = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + postScheduledAt: 1620100000000, + }; + + renderWithIntlAndTheme(); + + expect(screen.getByTestId('scheduled_post_header.scheduled_at')).toBeTruthy(); + expect(screen.getByText('Send on May 5, 2025, 10:00 AM')).toBeTruthy(); + }); + + it('renders scheduled post with error correctly', () => { + const errorProps = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + postScheduledAt: 1620100000000, + scheduledPostErrorCode: 'channel_not_found', + }; + + renderWithIntlAndTheme(); + + expect(screen.getByText('Error message')).toBeTruthy(); + }); + + it('renders sent scheduled post correctly', () => { + const sentProps = { + ...baseProps, + draftType: DRAFT_TYPE_SCHEDULED, + postScheduledAt: 1620100000000, + scheduledPostErrorCode: 'post_send_success_delete_failed', + }; + + renderWithIntlAndTheme(); + + expect(screen.getByText('Sent')).toBeTruthy(); + }); +}); diff --git a/app/components/drafts_buttton/index.test.tsx b/app/components/drafts_buttton/index.test.tsx new file mode 100644 index 000000000..8f7f6d1c6 --- /dev/null +++ b/app/components/drafts_buttton/index.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Screens} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import DraftsButton from './drafts_button'; + +import EnhancedDraftButton from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./drafts_button', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(DraftsButton).mockImplementation((props) => React.createElement('DraftsButton', {...props, testID: 'drafts-button'})); + +describe('DraftsButton', () => { + + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [TestHelper.fakeTeam({id: teamId})], prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should correctly show isActiveTab if the last channel id Global_DRAFTS', async () => { + await operator.handleTeamChannelHistory({ + teamChannelHistories: [ + {channel_ids: [Screens.GLOBAL_DRAFTS], id: teamId}, + ], + prepareRecordsOnly: false, + }); + + const {getByTestId} = renderWithEverything( + , {database}); + + const draftsButton = getByTestId('drafts-button'); + expect(draftsButton.props.isActiveTab).toBe(true); + }); + + it('should correctly show isActiveTab if the last channel id is not Global_DRAFTS', async () => { + await operator.handleTeamChannelHistory({ + teamChannelHistories: [ + {channel_ids: ['channel1'], id: teamId}, + ], + prepareRecordsOnly: false, + }); + + const {getByTestId} = renderWithEverything( + , {database}); + + const draftsButton = getByTestId('drafts-button'); + expect(draftsButton.props.isActiveTab).toBe(false); + }); + + it('if shouldHightlightActive is true and no last channel id is present, then isActiveTab should be true', async () => { + const {getByTestId} = renderWithEverything( + , {database}); + + const draftsButton = getByTestId('drafts-button'); + expect(draftsButton.props.isActiveTab).toBe(true); + }); +}); diff --git a/app/components/drafts_buttton/index.ts b/app/components/drafts_buttton/index.ts index b4c22c514..ad8f91382 100644 --- a/app/components/drafts_buttton/index.ts +++ b/app/components/drafts_buttton/index.ts @@ -13,7 +13,11 @@ import DraftsButton from './drafts_button'; import type {WithDatabaseArgs} from '@typings/database/database'; -const enhanced = withObservables(['shouldHighlightActive'], ({database, shouldHighlightActive}: {shouldHighlightActive: boolean} & WithDatabaseArgs) => { +type Props = WithDatabaseArgs & { + shouldHighlightActive: boolean; +} + +const enhanced = withObservables(['shouldHighlightActive'], ({database, shouldHighlightActive}: Props) => { const currentTeamId = observeCurrentTeamId(database); const isActiveTab = currentTeamId.pipe( switchMap( diff --git a/app/components/post_draft/send_button/index.test.tsx b/app/components/post_draft/send_button/index.test.tsx new file mode 100644 index 000000000..2d770e012 --- /dev/null +++ b/app/components/post_draft/send_button/index.test.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {storeGlobal} from '@actions/app/global'; +import {Tutorial} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {renderWithEverything, waitFor} from '@test/intl-test-helper'; + +import SendButton from './send_button'; + +import EnhancedSendButton from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./send_button', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(SendButton).mockImplementation((props) => React.createElement('SendButton', {...props, testID: 'send-button'})); + +describe('SendButton', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + await storeGlobal(Tutorial.SCHEDULED_POST, null, false); + }); + + const defaultProps: Parameters[0] = { + testID: 'send-button', + disabled: false, + sendMessage: jest.fn(), + showScheduledPostOptions: jest.fn(), + scheduledPostEnabled: true, + }; + + it('should return false if the scheduled post tutorial is not watched', async () => { + const {getByTestId} = renderWithEverything(, {database}); + const sendButton = getByTestId('send-button'); + expect(sendButton.props.scheduledPostFeatureTooltipWatched).toBe(false); + }); + + it('should return true if the scheduled post tutorial is watched', async () => { + await storeGlobal(Tutorial.SCHEDULED_POST, 'true', false); + await waitFor(() => { + const {getByTestId} = renderWithEverything(, {database}); + const sendButton = getByTestId('send-button'); + expect(sendButton.props.scheduledPostFeatureTooltipWatched).toBe(true); + }); + }); +}); diff --git a/app/components/post_draft/send_button/index.tsx b/app/components/post_draft/send_button/index.tsx index 5674fcfa6..c3005e86b 100644 --- a/app/components/post_draft/send_button/index.tsx +++ b/app/components/post_draft/send_button/index.tsx @@ -3,7 +3,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; -import {SendButton} from '@components/post_draft/send_button/send_button'; +import SendButton from '@components/post_draft/send_button/send_button'; import {Tutorial} from '@constants'; import {observeTutorialWatched} from '@queries/app/global'; diff --git a/app/components/post_draft/send_button/send_button.test.tsx b/app/components/post_draft/send_button/send_button.test.tsx index b4ca88016..28a2b0081 100644 --- a/app/components/post_draft/send_button/send_button.test.tsx +++ b/app/components/post_draft/send_button/send_button.test.tsx @@ -5,7 +5,7 @@ import {act} from '@testing-library/react-hooks'; import React from 'react'; import {InteractionManager} from 'react-native'; -import {SendButton} from '@components/post_draft/send_button/send_button'; +import SendButton from '@components/post_draft/send_button/send_button'; import {fireEvent, renderWithIntl} from '@test/intl-test-helper'; describe('components/post_draft/send_button', () => { diff --git a/app/components/post_draft/send_button/send_button.tsx b/app/components/post_draft/send_button/send_button.tsx index 1c53318ee..ca6515041 100644 --- a/app/components/post_draft/send_button/send_button.tsx +++ b/app/components/post_draft/send_button/send_button.tsx @@ -51,14 +51,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }; }); -export function SendButton({ +const SendButton: React.FC = ({ testID, disabled, sendMessage, showScheduledPostOptions, scheduledPostFeatureTooltipWatched, scheduledPostEnabled, -}: Props) { +}: Props) => { const theme = useTheme(); const sendButtonTestID = `${testID}.send.button` + (disabled ? '.disabled' : ''); const style = getStyleSheet(theme); @@ -116,4 +116,6 @@ export function SendButton({ ); -} +}; + +export default SendButton; diff --git a/app/queries/servers/scheduled_post.test.ts b/app/queries/servers/scheduled_post.test.ts new file mode 100644 index 000000000..3e4234a5e --- /dev/null +++ b/app/queries/servers/scheduled_post.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} from '@nozbe/watermelondb'; + +import {ActionType} from '@constants'; +import DatabaseManager from '@database/manager'; +import ServerDataOperator from '@database/operator/server_data_operator'; +import TestHelper from '@test/test_helper'; + +import { + observeFirstScheduledPost, + observeScheduledPostCount, + observeScheduledPostCountForChannel, + observeScheduledPostCountForThread, + observeScheduledPostsForTeam, + queryScheduledPost, + queryScheduledPostsForTeam, +} from './scheduled_post'; + +describe('Scheduled Post Queries', () => { + const serverUrl = 'scheduledpost.test.com'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('queryScheduledPostsForTeam', () => { + it('should return posts for specific team', async () => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', type: 'O'}); + const post = TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', message: 'Test'}); + + const models = (await Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: true}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: [post], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + const result = await queryScheduledPostsForTeam(database, 'team1').fetch(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('post1'); + }); + + it('should return empty for non-existent team', async () => { + const result = await queryScheduledPostsForTeam(database, 'nonexistent').fetch(); + expect(result).toHaveLength(0); + }); + + it('should include direct channels when specified', async () => { + const dmChannel = TestHelper.fakeChannel({id: 'dm1', team_id: '', type: 'D'}); + const post = TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'dm1', message: 'DM'}); + + const models = (await Promise.all([ + operator.handleChannel({channels: [dmChannel], prepareRecordsOnly: true}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: [post], + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + const result = await queryScheduledPostsForTeam(database, 'team1', true).fetch(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('post1'); + }); + }); + + describe('queryScheduledPost', () => { + it('should return posts for specific channel and root', async () => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1'}); + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', root_id: 'root1', message: 'Reply1'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'ch1', root_id: 'root1', message: 'Reply2'}), + TestHelper.fakeScheduledPost({id: 'post3', channel_id: 'ch1', root_id: 'root2', message: 'Other'}), + ]; + + const models = (await Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: true}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: true, + }), + ])).flat(); + await operator.batchRecords(models, 'test'); + + const result = await queryScheduledPost(database, 'ch1', 'root1').fetch(); + expect(result).toHaveLength(2); + expect(result.map((p) => p.id).sort()).toEqual(['post1', 'post2']); + }); + }); + + describe('observeScheduledPostsForTeam', () => { + it('should observe team posts', (done) => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', type: 'O'}); + const post = TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', message: 'Test'}); + + Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: [post], + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostsForTeam(database, 'team1').subscribe((posts) => { + if (posts.length === 1 && posts[0].id === 'post1') { + done(); + } + }); + }); + }, 1500); + }); + + describe('observeScheduledPostCount', () => { + it('should count team posts correctly', (done) => { + const channels = [ + TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', type: 'O'}), + TestHelper.fakeChannel({id: 'ch2', team_id: 'team1', type: 'P'}), + ]; + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', message: 'Test1'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'ch2', message: 'Test2'}), + ]; + + Promise.all([ + operator.handleChannel({channels, prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostCount(database, 'team1', false).subscribe((count) => { + if (count === 2) { + done(); + } + }); + }); + }, 1500); + + it('should include direct channels when specified', (done) => { + const channels = [ + TestHelper.fakeChannel({id: 'ch1', team_id: 'team1', type: 'O'}), + TestHelper.fakeChannel({id: 'dm1', team_id: '', type: 'D'}), + ]; + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', message: 'Team'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'dm1', message: 'DM'}), + ]; + + Promise.all([ + operator.handleChannel({channels, prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostCount(database, 'team1', true).subscribe((count) => { + if (count === 2) { + done(); + } + }); + }); + }, 1500); + }); + + describe('observeScheduledPostCountForChannel', () => { + it('should count channel posts without errors', (done) => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1'}); + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', error_code: '', message: 'Success1'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'ch1', error_code: '', message: 'Success2'}), + TestHelper.fakeScheduledPost({id: 'post3', channel_id: 'ch1', error_code: 'failed', message: 'Error'}), + ]; + + Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostCountForChannel(database, 'ch1', false).subscribe((count) => { + if (count === 2) { // Only posts without errors + done(); + } + }); + }); + }, 1500); + + it('should handle CRT filtering', (done) => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1'}); + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', root_id: '', error_code: '', message: 'Root1'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'ch1', root_id: '', error_code: '', message: 'Root2'}), + TestHelper.fakeScheduledPost({id: 'post3', channel_id: 'ch1', root_id: 'thread1', error_code: '', message: 'Reply'}), + ]; + + Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostCountForChannel(database, 'ch1', true).subscribe((count) => { + if (count === 2) { // Only root posts when CRT enabled + done(); + } + }); + }); + }, 1500); + }); + + describe('observeScheduledPostCountForThread', () => { + it('should count thread replies only', (done) => { + const channel = TestHelper.fakeChannel({id: 'ch1', team_id: 'team1'}); + const posts = [ + TestHelper.fakeScheduledPost({id: 'post1', channel_id: 'ch1', root_id: 'thread1', error_code: '', message: 'Reply1'}), + TestHelper.fakeScheduledPost({id: 'post2', channel_id: 'ch1', root_id: 'thread1', error_code: '', message: 'Reply2'}), + TestHelper.fakeScheduledPost({id: 'post3', channel_id: 'ch1', root_id: 'thread2', error_code: '', message: 'Other'}), + TestHelper.fakeScheduledPost({id: 'post4', channel_id: 'ch1', root_id: '', error_code: '', message: 'Root'}), + ]; + + Promise.all([ + operator.handleChannel({channels: [channel], prepareRecordsOnly: false}), + operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts: posts, + prepareRecordsOnly: false, + }), + ]).then(() => { + observeScheduledPostCountForThread(database, 'thread1').subscribe((count) => { + if (count === 2) { // Only replies to thread1 + done(); + } + }); + }); + }, 1500); + }); + + describe('observeFirstScheduledPost', () => { + it('should return undefined for empty array', (done) => { + observeFirstScheduledPost([]).subscribe((result) => { + if (result === undefined) { + done(); + } + }); + }); + + it('should return first post when available', (done) => { + const mockPost = TestHelper.fakeScheduledPostModel({ + id: 'first_post', + observe: jest.fn().mockReturnValue({ + subscribe: (callback: (result: any) => void) => { + callback({id: 'first_post', message: 'First post'}); + return {unsubscribe: jest.fn()}; + }, + }), + }); + + observeFirstScheduledPost([mockPost]).subscribe((result) => { + if (result?.id === 'first_post') { + done(); + } + }); + }); + }); +}); diff --git a/app/queries/servers/scheduled_post.ts b/app/queries/servers/scheduled_post.ts index 713d5e4e0..cfe6c2284 100644 --- a/app/queries/servers/scheduled_post.ts +++ b/app/queries/servers/scheduled_post.ts @@ -13,7 +13,7 @@ import type ScheduledPostModel from '@typings/database/models/servers/scheduled_ const {SERVER: {CHANNEL, SCHEDULED_POST}} = MM_TABLES; export const queryScheduledPostsForTeam = (database: Database, teamId: string, includeDirectChannelPosts?: boolean) => { - return database.collections.get(SCHEDULED_POST).query( + return database.get(SCHEDULED_POST).query( Q.on(CHANNEL, Q.or( Q.where('team_id', teamId), @@ -27,7 +27,7 @@ export const queryScheduledPostsForTeam = (database: Database, teamId: string, i }; export const queryScheduledPost = (database: Database, channelId: string, rootId = '') => { - return database.collections.get(SCHEDULED_POST).query( + return database.get(SCHEDULED_POST).query( Q.and( Q.where('channel_id', channelId), Q.where('root_id', rootId), @@ -67,7 +67,7 @@ export const observeScheduledPostCountForChannel = ( }; export const observeScheduledPostCountForThread = (database: Database, rootId: string) => { - return database.collections.get(SCHEDULED_POST).query( + return database.get(SCHEDULED_POST).query( Q.where('root_id', rootId), ).observeCount(); }; diff --git a/app/screens/global_drafts/components/global_scheduled_post_list/index.test.tsx b/app/screens/global_drafts/components/global_scheduled_post_list/index.test.tsx new file mode 100644 index 000000000..3339db626 --- /dev/null +++ b/app/screens/global_drafts/components/global_scheduled_post_list/index.test.tsx @@ -0,0 +1,234 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {storeGlobal} from '@actions/app/global'; +import {ActionType, Tutorial, Screens} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager'; +import {act, renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import GlobalScheduledPostList from './global_scheduled_post_list'; + +import EnhancedGlobalScheduledPostList from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; +import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post'; + +jest.mock('./global_scheduled_post_list', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(GlobalScheduledPostList).mockImplementation((props) => React.createElement('GlobalScheduledPostList', {...props, testID: 'global-scheduled-post-list'})); + +describe('GlobalScheduledPostList', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false}); + await operator.handleTeam({teams: [TestHelper.fakeTeam({id: teamId})], prepareRecordsOnly: false}); + await operator.handleConfigs({ + configs: [ + {id: 'Version', value: '7.6.0'}, + ], + configsToDelete: [], + prepareRecordsOnly: false, + }); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should return empty array is there is no scheduled post', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + const globalScheduledPostList = getByTestId('global-scheduled-post-list'); + expect(globalScheduledPostList.props.allScheduledPosts).toStrictEqual([]); + }); + + it('should show scheduled post when one exists', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + const globalScheduledPostList = getByTestId('global-scheduled-post-list'); + expect(globalScheduledPostList.props.allScheduledPosts).toStrictEqual([]); + + const channelId = 'channel1'; + await operator.handleChannel({ + channels: [TestHelper.fakeChannel({id: channelId, team_id: teamId})], + prepareRecordsOnly: false, + }); + + const scheduledPosts = [TestHelper.fakeScheduledPost({ + message: 'test message', + files: [], + channel_id: channelId, + })]; + + await act(async () => { + await operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts, + includeDirectChannelPosts: false, + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + const updatedGlobalScheduledPostList = getByTestId('global-scheduled-post-list'); + expect(updatedGlobalScheduledPostList.props.allScheduledPosts).toHaveLength(1); + expect(updatedGlobalScheduledPostList.props.allScheduledPosts[0].id).toStrictEqual(scheduledPosts[0].id); + }); + }); + + it('should correctly handle tutorial watched state', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + const globalScheduledPostList = getByTestId('global-scheduled-post-list'); + expect(globalScheduledPostList.props.tutorialWatched).toBe(false); + + await storeGlobal(Tutorial.SCHEDULED_POSTS_LIST, 'true', false); + + await waitFor(() => { + const updatedGlobalScheduledPostList = getByTestId('global-scheduled-post-list'); + expect(updatedGlobalScheduledPostList.props.tutorialWatched).toBe(true); + }); + }); + + it('should only show scheduled posts for current team', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + const team1ChannelId = 'team1_channel'; + const team2ChannelId = 'team2_channel'; + + await operator.handleChannel({ + channels: [ + TestHelper.fakeChannel({id: team1ChannelId, team_id: teamId, type: 'O'}), + TestHelper.fakeChannel({id: team2ChannelId, team_id: 'team2', type: 'O'}), + ], + prepareRecordsOnly: false, + }); + + const scheduledPosts = [ + TestHelper.fakeScheduledPost({ + id: 'post1', + message: 'Team1 message', + channel_id: team1ChannelId, + }), + TestHelper.fakeScheduledPost({ + id: 'post2', + message: 'Team2 message', + channel_id: team2ChannelId, + }), + ]; + + await act(async () => { + await operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts, + includeDirectChannelPosts: false, + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + const globalScheduledPostList = getByTestId('global-scheduled-post-list'); + + // Should only show the post from current team (team1) + expect(globalScheduledPostList.props.allScheduledPosts).toHaveLength(1); + expect(globalScheduledPostList.props.allScheduledPosts[0].id).toBe('post1'); + }); + }); + + it('should include DMs and GMs in scheduled posts', async () => { + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + const teamChannelId = 'team_channel'; + const dmChannelId = 'dm_channel'; + const gmChannelId = 'gm_channel'; + + await operator.handleChannel({ + channels: [ + TestHelper.fakeChannel({id: teamChannelId, team_id: teamId, type: 'O'}), // Team channel + TestHelper.fakeChannel({id: dmChannelId, team_id: '', type: 'D'}), // Direct message + TestHelper.fakeChannel({id: gmChannelId, team_id: '', type: 'G'}), // Group message + ], + prepareRecordsOnly: false, + }); + + const scheduledPosts = [ + TestHelper.fakeScheduledPost({ + id: 'team_post', + message: 'Team message', + channel_id: teamChannelId, + }), + TestHelper.fakeScheduledPost({ + id: 'dm_post', + message: 'DM message', + channel_id: dmChannelId, + }), + TestHelper.fakeScheduledPost({ + id: 'gm_post', + message: 'GM message', + channel_id: gmChannelId, + }), + ]; + + await act(async () => { + await operator.handleScheduledPosts({ + actionType: ActionType.SCHEDULED_POSTS.CREATE_OR_UPDATED_SCHEDULED_POST, + scheduledPosts, + includeDirectChannelPosts: true, + prepareRecordsOnly: false, + }); + }); + + await waitFor(() => { + const globalScheduledPostList = getByTestId('global-scheduled-post-list'); + + expect(globalScheduledPostList.props.allScheduledPosts).toHaveLength(3); + + const postIds = globalScheduledPostList.props.allScheduledPosts.map((post: ScheduledPostModel) => post.id); + expect(postIds).toContain('team_post'); + expect(postIds).toContain('dm_post'); + expect(postIds).toContain('gm_post'); + }); + }); +}); diff --git a/app/screens/reschedule_draft/indext.test.tsx b/app/screens/reschedule_draft/indext.test.tsx new file mode 100644 index 000000000..63a8e708b --- /dev/null +++ b/app/screens/reschedule_draft/indext.test.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Screens} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager/__mocks__'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import RescheduledDraft from './reschedule_draft'; + +import EnhancedRescheduledDraft from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./reschedule_draft', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mocked(RescheduledDraft).mockImplementation((props) => React.createElement('RescheduledDraft', {...props, testID: 'reschedule-draft'})); + +describe('EnhancedRescheduledDraft', () => { + + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'}], prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should correctly return the current user timezone', async () => { + await operator.handleUsers({users: [TestHelper.fakeUser({id: 'user1', timezone: {useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}})], prepareRecordsOnly: false}); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('reschedule-draft')).toBeTruthy(); + expect(getByTestId('reschedule-draft').props.currentUserTimezone).toStrictEqual({ + useAutomaticTimezone: false, + manualTimezone: 'America/New_York', + automaticTimezone: 'America/New_York', + }); + }); + + it('should return undefined if the current user timezone is not set', async () => { + await operator.handleUsers({users: [TestHelper.fakeUser({id: 'user1', timezone: undefined})], prepareRecordsOnly: false}); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('reschedule-draft')).toBeTruthy(); + expect(getByTestId('reschedule-draft').props.currentUserTimezone).toBeUndefined(); + }); +}); diff --git a/app/screens/scheduled_post_options/core_options/core_options.test.tsx b/app/screens/scheduled_post_options/core_options/core_options.test.tsx new file mode 100644 index 000000000..a19fc343d --- /dev/null +++ b/app/screens/scheduled_post_options/core_options/core_options.test.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent} from '@testing-library/react-native'; +import moment from 'moment-timezone'; +import React from 'react'; + +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import {ScheduledPostCoreOptions} from './core_options'; + +import type Database from '@nozbe/watermelondb/Database'; + +jest.mock('@utils/time', () => ({ + getFormattedTime: jest.fn().mockReturnValue('9:00 AM'), +})); + +describe('ScheduledPostCoreOptions', () => { + let database: Database; + const baseProps = { + userTimezone: 'America/New_York', + isMilitaryTime: false, + onSelectOption: jest.fn(), + onCustomTimeSelected: jest.fn(), + }; + + beforeAll(async () => { + const server = await TestHelper.setupServerDatabase(); + database = server.database; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly for Sunday', () => { + // Sunday is weekday 7 in moment + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(7); + + const {getByText, queryByText} = renderWithEverything( + , + {database}, + ); + + // Should show Tomorrow option + expect(getByText('Tomorrow at 9:00 AM')).toBeTruthy(); + + // Should not show Monday or Next Monday options + expect(queryByText('Monday at 9:00 AM')).toBeNull(); + expect(queryByText('Next Monday at 9:00 AM')).toBeNull(); + + // Should always show Custom Time option + expect(getByText('Custom Time')).toBeTruthy(); + }); + + it('renders correctly for Monday', () => { + // Monday is weekday 1 in moment + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(1); + + const {getByText, queryByText} = renderWithEverything( + , + {database}, + ); + + // Should show Tomorrow and Next Monday options + expect(getByText('Tomorrow at 9:00 AM')).toBeTruthy(); + expect(getByText('Next Monday at 9:00 AM')).toBeTruthy(); + + // Should not show Monday option + expect(queryByText('Monday at 9:00 AM')).toBeNull(); + + // Should always show Custom Time option + expect(getByText('Custom Time')).toBeTruthy(); + }); + + it('renders correctly for Friday', () => { + // Friday is weekday 5 in moment + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(5); + + const {getByText, queryByText} = renderWithEverything( + , + {database}, + ); + + // Should show Monday option + expect(getByText('Monday at 9:00 AM')).toBeTruthy(); + + // Should not show Tomorrow or Next Monday options + expect(queryByText('Tomorrow at 9:00 AM')).toBeNull(); + expect(queryByText('Next Monday at 9:00 AM')).toBeNull(); + + // Should always show Custom Time option + expect(getByText('Custom Time')).toBeTruthy(); + }); + + it('renders correctly for Saturday', () => { + // Saturday is weekday 6 in moment + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(6); + + const {getByText, queryByText} = renderWithEverything( + , + {database}, + ); + + // Should show Monday option + expect(getByText('Monday at 9:00 AM')).toBeTruthy(); + + // Should not show Tomorrow or Next Monday options + expect(queryByText('Tomorrow at 9:00 AM')).toBeNull(); + expect(queryByText('Next Monday at 9:00 AM')).toBeNull(); + + // Should always show Custom Time option + expect(getByText('Custom Time')).toBeTruthy(); + }); + + it('calls onSelectOption with correct timestamp when Tomorrow is selected', () => { + const mockDate = moment.tz('2023-05-15 12:00', 'America/New_York'); + jest.spyOn(moment, 'tz').mockReturnValue(mockDate); + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(1); + jest.spyOn(moment.prototype, 'clone').mockReturnValue(mockDate); + jest.spyOn(moment.prototype, 'valueOf').mockReturnValue(1684242000000); + + const {getByText} = renderWithEverything( + , + {database}, + ); + + fireEvent.press(getByText('Tomorrow at 9:00 AM')); + + expect(baseProps.onSelectOption).toHaveBeenCalledWith('1684242000000'); + expect(baseProps.onCustomTimeSelected).toHaveBeenCalledWith(false); + }); + + it('calls onSelectOption with correct timestamp when Monday is selected', () => { + const mockDate = moment.tz('2023-05-11 12:00', 'America/New_York'); + jest.spyOn(moment, 'tz').mockReturnValue(mockDate); + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(4); + + const {getByText} = renderWithEverything( + , + {database}, + ); + + fireEvent.press(getByText('Monday at 9:00 AM')); + + const expectedTime = mockDate.clone().isoWeekday(1).add(1, 'week').startOf('day').hour(9).minute(0); + expect(baseProps.onSelectOption).toHaveBeenCalledWith(expectedTime.valueOf().toString()); + expect(baseProps.onCustomTimeSelected).toHaveBeenCalledWith(false); + }); + + it('calls onSelectOption with correct timestamp when Next Monday is selected', () => { + const mockDate = moment.tz('2023-05-15 12:00', 'America/New_York'); + jest.spyOn(moment, 'tz').mockReturnValue(mockDate); + jest.spyOn(moment.prototype, 'weekday').mockReturnValue(1); + + const {getByText} = renderWithEverything( + , + {database}, + ); + + fireEvent.press(getByText('Next Monday at 9:00 AM')); + + const expectedTime = mockDate.clone().isoWeekday(1).add(1, 'week').startOf('day').hour(9).minute(0); + expect(baseProps.onSelectOption).toHaveBeenCalledWith(expectedTime.valueOf().toString()); + expect(baseProps.onCustomTimeSelected).toHaveBeenCalledWith(false); + }); + + it('shows DateTimeSelector when Custom Time is selected', () => { + const {getByText, queryByTestId} = renderWithEverything( + , + {database}, + ); + + // Initially, DateTimeSelector should not be visible + expect(queryByTestId('custom_date_time_picker')).toBeNull(); + + // Select Custom Time option + fireEvent.press(getByText('Custom Time')); + + // DateTimeSelector should now be visible + expect(queryByTestId('custom_date_time_picker')).not.toBeNull(); + + // Should call onCustomTimeSelected with true + expect(baseProps.onCustomTimeSelected).toHaveBeenCalledWith(true); + }); + + it('calls handleCustomTimeChange when a custom time is selected', () => { + // This test would need to mock the DateTimeSelector component's behavior + // Since we can't directly test the internal callback without exposing it, + // we'll verify that the component is rendered and the callback is set up + + const {getByText} = renderWithEverything( + , + {database}, + ); + + // Select Custom Time option + fireEvent.press(getByText('Custom Time')); + + // Verify onCustomTimeSelected was called + expect(baseProps.onCustomTimeSelected).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/screens/scheduled_post_options/core_options/index.test.tsx b/app/screens/scheduled_post_options/core_options/index.test.tsx new file mode 100644 index 000000000..50c076a03 --- /dev/null +++ b/app/screens/scheduled_post_options/core_options/index.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Preferences} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager/__mocks__'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; +import {getTimezone} from '@utils/user'; + +import {ScheduledPostCoreOptions} from './core_options'; + +import EnhancedScheduledPostCoreOptions from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./core_options', () => ({ + __esModule: true, + ScheduledPostCoreOptions: jest.fn(), +})); + +jest.mocked(ScheduledPostCoreOptions).mockImplementation((props) => React.createElement('ScheduledPostCoreOptions', {...props, testID: 'scheduled-post-core-options'})); + +describe('EnhancedScheduledPostCoreOptions', () => { + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleUsers({users: [TestHelper.fakeUser({id: 'user1', timezone: {useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}})], prepareRecordsOnly: false}); + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'}], prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should correctly return the isMilitaryTime preference', async () => { + await operator.handlePreferences({ + preferences: [{ + category: Preferences.CATEGORIES.DISPLAY_SETTINGS, + name: 'use_military_time', + user_id: 'user1', + value: 'true', + }], + prepareRecordsOnly: false, + }); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('scheduled-post-core-options')).toBeTruthy(); + expect(getByTestId('scheduled-post-core-options').props.isMilitaryTime).toBe(true); + }); + + it('should return false if the isMilitaryTime preference is not set', async () => { + await operator.handlePreferences({ + preferences: [], + prepareRecordsOnly: false, + }); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('scheduled-post-core-options')).toBeTruthy(); + expect(getByTestId('scheduled-post-core-options').props.isMilitaryTime).toBe(false); + }); +}); diff --git a/app/screens/scheduled_post_options/index.test.tsx b/app/screens/scheduled_post_options/index.test.tsx new file mode 100644 index 000000000..520490e89 --- /dev/null +++ b/app/screens/scheduled_post_options/index.test.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {SYSTEM_IDENTIFIERS} from '@constants/database'; +import DatabaseManager from '@database/manager/__mocks__'; +import {ScheduledPostOptions} from '@screens/scheduled_post_options/scheduled_post_picker'; +import {renderWithEverything} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import EnhancedScheduledPostPicker from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./scheduled_post_picker', () => ({ + __esModule: true, + ScheduledPostOptions: jest.fn(), +})); + +jest.mocked(ScheduledPostOptions).mockImplementation((props) => React.createElement('ScheduledPostOptions', {...props, testID: 'scheduled-post-options'})); + +describe('EnhancedRescheduledDraft', () => { + + const serverUrl = 'server-1'; + const teamId = 'team1'; + let database: Database; + let operator: ServerDataOperator; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + + await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}, {id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user1'}], prepareRecordsOnly: false}); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + it('should correctly return the current user timezone', async () => { + await operator.handleUsers({users: [TestHelper.fakeUser({id: 'user1', timezone: {useAutomaticTimezone: false, manualTimezone: 'America/New_York', automaticTimezone: 'America/New_York'}})], prepareRecordsOnly: false}); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('scheduled-post-options')).toBeTruthy(); + expect(getByTestId('scheduled-post-options').props.currentUserTimezone).toStrictEqual({ + useAutomaticTimezone: false, + manualTimezone: 'America/New_York', + automaticTimezone: 'America/New_York', + }); + }); + + it('should return undefined if the current user timezone is not set', async () => { + await operator.handleUsers({users: [TestHelper.fakeUser({id: 'user1', timezone: undefined})], prepareRecordsOnly: false}); + + const {getByTestId} = renderWithEverything( + , + {database}, + ); + + expect(getByTestId('scheduled-post-options')).toBeTruthy(); + expect(getByTestId('scheduled-post-options').props.currentUserTimezone).toBeUndefined(); + }); +}); diff --git a/test/test_helper.ts b/test/test_helper.ts index 4fc2ed89f..b711d7ee1 100644 --- a/test/test_helper.ts +++ b/test/test_helper.ts @@ -37,6 +37,7 @@ import type PostModel from '@typings/database/models/servers/post'; import type PostsInChannelModel from '@typings/database/models/servers/posts_in_channel'; import type PreferenceModel from '@typings/database/models/servers/preference'; import type RoleModel from '@typings/database/models/servers/role'; +import type ScheduledPostModel from '@typings/database/models/servers/scheduled_post'; import type TeamModel from '@typings/database/models/servers/team'; import type ThreadModel from '@typings/database/models/servers/thread'; import type UserModel from '@typings/database/models/servers/user'; @@ -165,7 +166,7 @@ class TestHelperSingleton { if (c === 'x') { v = r; } else { - // eslint-disable-next-line no-mixed-operators + v = (r & 0x3) | 0x8; } @@ -855,6 +856,43 @@ class TestHelperSingleton { }; }; + fakeScheduledPostModel = (overwrite?: Partial): ScheduledPostModel => { + return { + ...this.fakeModel(), + id: this.generateId(), + createAt: 0, + updateAt: 0, + channelId: this.generateId(), + message: '', + rootId: '', + files: [], + metadata: {}, + scheduledAt: 0, + processedAt: 0, + errorCode: '', + toApi: jest.fn(), + ...overwrite, + }; + }; + + fakeScheduledPost = (overwrite?: Partial): ScheduledPost => { + return { + id: this.generateId(), + channel_id: this.generateId(), + message: '', + root_id: '', + files: [], + metadata: {}, + create_at: Date.now(), + update_at: Date.now(), + scheduled_at: Date.now(), + processed_at: 0, + error_code: '', + user_id: this.generateId(), + ...overwrite, + }; + }; + fakePreferenceModel = (overwrite?: Partial): PreferenceModel => { return { ...this.fakeModel(),