import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import MockAdapter from 'axios-mock-adapter'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, Route } from 'react-router'; import { Factory } from 'rosie'; import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeStore } from '../../store'; import { executeThunk } from '../../test-utils'; import { DiscussionContext } from '../common/context'; import { getCourseConfigApiUrl } from '../data/api'; import { fetchCourseConfig } from '../data/thunks'; import DiscussionContent from '../discussions-home/DiscussionContent'; import { getThreadsApiUrl } from '../posts/data/api'; import { fetchThreads } from '../posts/data/thunks'; import { getCommentsApiUrl } from './data/api'; import { removeComment } from './data/thunks'; import '../posts/data/__factories__'; import './data/__factories__'; const courseConfigApiUrl = getCourseConfigApiUrl(); const commentsApiUrl = getCommentsApiUrl(); const threadsApiUrl = getThreadsApiUrl(); const discussionPostId = 'thread-1'; const questionPostId = 'thread-2'; const closedPostId = 'thread-2'; const courseId = 'course-v1:edX+TestX+Test_Course'; const reverseOrder = false; let store; let axiosMock; let testLocation; let container; function mockAxiosReturnPagedComments() { [null, false, true].forEach(endorsed => { const postId = endorsed === null ? discussionPostId : questionPostId; [1, 2].forEach(page => { axiosMock .onGet(commentsApiUrl, { params: { thread_id: postId, page, page_size: undefined, requested_fields: 'profile_image', endorsed, reverse_order: reverseOrder, }, }) .reply(200, Factory.build('commentsResult', { can_delete: true }, { threadId: postId, page, pageSize: 1, count: 2, endorsed, childCount: page === 1 ? 2 : 0, })); }); }); } function mockAxiosReturnPagedCommentsResponses() { const parentId = 'comment-1'; const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`; const paramsTemplate = { page: undefined, page_size: undefined, requested_fields: 'profile_image', }; for (let page = 1; page <= 2; page++) { axiosMock .onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }) .reply(200, Factory.build('commentsResult', null, { parentId, page, pageSize: 1, count: 2, })); } } function renderComponent(postId) { const wrapper = render( { testLocation = location; return null; }} /> , ); container = wrapper.container; } describe('ThreadView', () => { beforeEach(() => { initializeMockApp({ authenticatedUser: { userId: 3, username: 'abc123', administrator: true, roles: [], }, }); store = initializeStore(); Factory.resetAll(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock.onGet(threadsApiUrl) .reply(200, Factory.build('threadsResult')); axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({ url, data, }) => { const commentId = url.match(/comments\/(?[a-z1-9-]+)\//).groups.id; const { rawBody, } = camelCaseObject(JSON.parse(data)); return [200, Factory.build('comment', { id: commentId, rendered_body: rawBody, raw_body: rawBody, })]; }); axiosMock.onPost(commentsApiUrl) .reply(({ data }) => { const { rawBody, threadId, } = camelCaseObject(JSON.parse(data)); return [200, Factory.build( 'comment', { rendered_body: rawBody, raw_body: rawBody, thread_id: threadId, }, )]; }); executeThunk(fetchThreads(courseId), store.dispatch, store.getState); mockAxiosReturnPagedComments(); mockAxiosReturnPagedCommentsResponses(); }); describe('for all post types', () => { function assertLastUpdateData(data) { expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data); } it('should show and hide the editor', async () => { renderComponent(discussionPostId); const post = screen.getByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i }); await act(async () => { fireEvent.click( addResponseButton, ); }); expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /cancel/i })); }); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); }); it('should allow posting a response', async () => { renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); const addResponseButton = within(hoverCard).getByRole('button', { name: /Add response/i }); await act(async () => { fireEvent.click( addResponseButton, ); }); await act(() => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click( screen.getByText(/submit/i), ); }); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); await waitFor(async () => expect(await screen.findByTestId('comment-1')).toBeInTheDocument()); }); it('should not allow posting a response on a closed post', async () => { renderComponent(closedPostId); const post = screen.getByTestId('post-thread-2'); const hoverCard = within(post).getByTestId('hover-card-thread-2'); expect(within(hoverCard).getByRole('button', { name: /Add response/i })).toBeDisabled(); }); it('should allow posting a comment', async () => { renderComponent(discussionPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /Add comment/i }), ); }); act(() => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click( screen.getByText(/submit/i), ); }); expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument(); await waitFor(async () => expect(await screen.findByTestId('reply-comment-7')).toBeInTheDocument()); }); it('should not allow posting a comment on a closed post', async () => { renderComponent(closedPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-3')); const hoverCard = within(comment).getByTestId('hover-card-comment-3'); expect(within(hoverCard).getByRole('button', { name: /Add comment/i })).toBeDisabled(); }); it('should allow editing an existing comment', async () => { renderComponent(discussionPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /edit/i })); }); act(() => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /submit/i })); }); await waitFor(async () => { expect(await screen.findByTestId('comment-comment-1')).toBeInTheDocument(); }); }); async function setupCourseConfig(reasonCodesEnabled = true) { axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { has_moderation_privileges: true, reason_codes_enabled: reasonCodesEnabled, editReasons: [ { code: 'reason-1', label: 'reason 1' }, { code: 'reason-2', label: 'reason 2' }, ], postCloseReasons: [ { code: 'reason-1', label: 'reason 1' }, { code: 'reason-2', label: 'reason 2' }, ], }); axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {}); await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState); } it('should show reason codes when editing an existing comment', async () => { setupCourseConfig(); renderComponent(discussionPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /edit/i })); }); expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument(); expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2); await act(async () => { fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: null } }); }); await act(async () => { fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: 'reason-1' } }); }); await act(async () => { fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } }); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /submit/i })); }); assertLastUpdateData({ edit_reason_code: 'reason-1' }); }); it('should show reason codes when closing a post', async () => { setupCourseConfig(); renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /close/i })); }); expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument(); expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument(); expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2); await act(async () => { fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } }); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /close post/i })); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' }); }); it('should close the post directly if reason codes are not enabled', async () => { setupCourseConfig(false); renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /close/i })); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); assertLastUpdateData({ closed: true }); }); it.each([true, false])( 'should reopen the post directly when reason codes enabled=%s', async (reasonCodesEnabled) => { setupCourseConfig(reasonCodesEnabled); renderComponent(closedPostId); const post = screen.getByTestId('post-thread-2'); const hoverCard = within(post).getByTestId('hover-card-thread-2'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /reopen/i })); }); expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument(); assertLastUpdateData({ closed: false }); }, ); it('should show the editor if the post is edited', async () => { setupCourseConfig(false); renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /edit/i })); }); expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`); }); it('should allow pinning the post', async () => { renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /pin/i })); }); assertLastUpdateData({ pinned: false }); }); it('should allow reporting the post', async () => { renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /report/i })); }); expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); }); expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); assertLastUpdateData({ abuse_flagged: true }); }); it('handles liking a post', async () => { renderComponent(discussionPostId); const post = await screen.findByTestId('post-thread-1'); const hoverCard = within(post).getByTestId('hover-card-thread-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /like/i }), ); }); expect(axiosMock.history.patch).toHaveLength(2); expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); }); it('handles liking a comment', async () => { renderComponent(discussionPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /like/i }), ); }); expect(axiosMock.history.patch).toHaveLength(2); expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true }); }); it('handles endorsing comments', async () => { renderComponent(discussionPostId); // Wait for the content to load const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click(within(hoverCard).getByRole('button', { name: /Endorse/i })); }); expect(axiosMock.history.patch).toHaveLength(2); expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true }); }); it('handles reporting comments', async () => { renderComponent(discussionPostId); // Wait for the content to load const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const hoverCard = within(comment).getByTestId('hover-card-comment-1'); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Report/i })); }); expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument(); await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Confirm/i })); }); expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument(); expect(axiosMock.history.patch).toHaveLength(2); expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true }); }); }); describe('for discussion thread', () => { const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments'); it('shown post not found when post id does not belong to course', async () => { renderComponent('unloaded-id'); expect(await screen.findByText('Thread not found', { exact: true })) .toBeInTheDocument(); }); it('initially loads only the first page', async () => { renderComponent(discussionPostId); expect(await screen.findByTestId('comment-comment-1')) .toBeInTheDocument(); expect(screen.queryByTestId('comment-comment-2')) .not .toBeInTheDocument(); }); it('pressing load more button will load next page of comments', async () => { renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsButton(); fireEvent.click(loadMoreButton); await screen.findByTestId('comment-comment-1'); await screen.findByTestId('comment-comment-2'); }); it('newly loaded comments are appended to the old ones', async () => { renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsButton(); fireEvent.click(loadMoreButton); await screen.findByTestId('comment-comment-1'); // check that comments from the first page are also displayed expect(screen.queryByTestId('comment-comment-2')) .toBeInTheDocument(); }); it('load more button is hidden when no more comments pages to load', async () => { const totalPages = 2; renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsButton(); for (let page = 1; page < totalPages; page++) { fireEvent.click(loadMoreButton); } await screen.findByTestId('comment-comment-2'); await expect(findLoadMoreCommentsButton()) .rejects .toThrow(); }); }); describe('for question thread', () => { const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments'); it('initially loads only the first page', async () => { act(() => renderComponent(questionPostId)); expect(await screen.findByTestId('comment-comment-3')) .toBeInTheDocument(); expect(await screen.findByTestId('comment-comment-5')) .toBeInTheDocument(); expect(screen.queryByTestId('comment-comment-4')) .not .toBeInTheDocument(); }); it('pressing load more button will load next page of comments', async () => { act(() => { renderComponent(questionPostId); }); const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons(); // Both load more buttons should show expect(await findLoadMoreCommentsButtons()).toHaveLength(2); expect(await screen.findByTestId('comment-comment-3')) .toBeInTheDocument(); expect(await screen.findByTestId('comment-comment-5')) .toBeInTheDocument(); // Comments from next page should not be loaded yet. expect(await screen.queryByTestId('comment-comment-6')) .not .toBeInTheDocument(); expect(await screen.queryByTestId('comment-comment-4')) .not .toBeInTheDocument(); await act(async () => { fireEvent.click(loadMoreButtonEndorsed); }); // Endorsed comment from next page should be loaded now. await waitFor(() => expect(screen.queryByTestId('comment-comment-6')) .toBeInTheDocument()); // Unendorsed comment from next page should not be loaded yet. expect(await screen.queryByTestId('comment-comment-4')) .not .toBeInTheDocument(); // Now only one load more buttons should show, for unendorsed comments expect(await findLoadMoreCommentsButtons()).toHaveLength(1); await act(async () => { fireEvent.click(loadMoreButtonUnendorsed); }); // Unendorsed comment from next page should be loaded now. await waitFor(() => expect(screen.queryByTestId('comment-comment-4')) .toBeInTheDocument()); await expect(findLoadMoreCommentsButtons()).rejects.toThrow(); }); }); describe('for comments replies', () => { const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses'); it('initially loads only the first page', async () => { renderComponent(discussionPostId); await waitFor(() => screen.findByTestId('reply-comment-7')); expect(screen.queryByTestId('reply-comment-8')).not.toBeInTheDocument(); }); it('pressing load more button will load next page of responses', async () => { renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsResponsesButton(); await act(async () => { fireEvent.click(loadMoreButton); }); await screen.findByTestId('reply-comment-8'); }); it('newly loaded responses are appended to the old ones', async () => { renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsResponsesButton(); await act(async () => { fireEvent.click(loadMoreButton); }); await screen.findByTestId('reply-comment-8'); // check that comments from the first page are also displayed expect(screen.queryByTestId('reply-comment-7')).toBeInTheDocument(); }); it('load more button is hidden when no more responses pages to load', async () => { const totalPages = 2; renderComponent(discussionPostId); const loadMoreButton = await findLoadMoreCommentsResponsesButton(); for (let page = 1; page < totalPages; page++) { act(() => { fireEvent.click(loadMoreButton); }); } await screen.findByTestId('reply-comment-8'); await expect(findLoadMoreCommentsResponsesButton()) .rejects .toThrow(); }); }); describe.each([ { component: 'post', testId: 'post-thread-1', cardId: 'hover-card-thread-1' }, { component: 'comment', testId: 'comment-comment-1', cardId: 'hover-card-comment-1' }, ])('delete confirmation modal', ({ component, testId, cardId, }) => { test(`for ${component}`, async () => { renderComponent(discussionPostId); // Wait for the content to load const post = await screen.findByTestId(testId); const hoverCard = within(post).getByTestId(cardId); expect(screen.queryByRole('dialog', { name: /Delete response/i, exact: false })).not.toBeInTheDocument(); await act(async () => { fireEvent.click( within(hoverCard).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Delete/i })); }); expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument(); }); }); describe('for comments replies', () => { it('shows delete confirmation modal', async () => { renderComponent(discussionPostId); const reply = await waitFor(() => screen.findByTestId('reply-comment-7')); await act(async () => { fireEvent.click( within(reply).getByRole('button', { name: /actions menu/i }), ); }); await act(async () => { fireEvent.click(screen.queryByRole('button', { name: /Delete/i })); }); expect(screen.queryByRole('dialog', { name: /Delete/i, exact: false })).toBeInTheDocument(); }); }); describe('for comments sort', () => { it('should show sort dropdown if there are endorse or unendorsed comments', async () => { renderComponent(discussionPostId); const comment = await waitFor(() => screen.findByTestId('comment-comment-1')); const sortWrapper = container.querySelector('.comments-sort'); const sortDropDown = within(sortWrapper).getByRole('button', { name: /Oldest first/i }); expect(comment).toBeInTheDocument(); expect(sortDropDown).toBeInTheDocument(); }); it('should not show sort dropdown if there is no response', async () => { const commentId = 'comment-1'; renderComponent(discussionPostId); await waitFor(() => screen.findByTestId('comment-comment-1')); axiosMock.onDelete(`${commentsApiUrl}${commentId}/`).reply(201); await executeThunk(removeComment(commentId, discussionPostId), store.dispatch, store.getState); expect(await waitFor(() => screen.findByText('No responses', { exact: true }))).toBeInTheDocument(); expect(container.querySelector('.comments-sort')).not.toBeInTheDocument(); }); it('should have only two options', async () => { renderComponent(discussionPostId); await waitFor(() => screen.findByTestId('comment-comment-1')); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); expect(dropdown).toBeInTheDocument(); expect(await within(dropdown).getAllByRole('button')).toHaveLength(2); }); it('should be selected Oldest first and auto focus', async () => { renderComponent(discussionPostId); await waitFor(() => screen.findByTestId('comment-comment-1')); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); expect(dropdown).toBeInTheDocument(); expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toBeInTheDocument(); expect(within(dropdown).getByRole('button', { name: /Oldest first/i })).toHaveFocus(); expect(within(dropdown).getByRole('button', { name: /Newest first/i })).not.toHaveFocus(); }); test('successfully handles sort state update', async () => { renderComponent(discussionPostId); expect(store.getState().comments.sortOrder).toBeFalsy(); await waitFor(() => screen.findByTestId('comment-comment-1')); await act(async () => { fireEvent.click(screen.getByRole('button', { name: /Oldest first/i })); }); const dropdown = await waitFor(() => screen.findByTestId('comment-sort-dropdown-modal-popup')); await act(async () => { fireEvent.click(within(dropdown).getByRole('button', { name: /Newest first/i })); }); expect(store.getState().comments.sortOrder).toBeTruthy(); }); }); });