diff options
-rw-r--r-- | jest.config.js | 3 | ||||
-rw-r--r-- | src/components/MessagesList/MessagesGroup/Message/Message.spec.js | 1060 | ||||
-rw-r--r-- | src/components/MessagesList/MessagesGroup/Message/Message.vue | 5 | ||||
-rw-r--r-- | src/test-setup.js | 3 |
4 files changed, 1070 insertions, 1 deletions
diff --git a/jest.config.js b/jest.config.js index b98e9334e..e45ffde02 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,6 +24,9 @@ module.exports = { preset: '@vue/cli-plugin-unit-jest/presets/no-babel', // Allow tests in the src and in tests/unit folders testMatch: ['<rootDir>/src/**/*.(spec|test).(ts|js)'], + transformIgnorePatterns: [ + 'node_modules/(?!(vue-material-design-icons|@juliushaertl)/)', + ], resetMocks: false, setupFiles: ['jest-localstorage-mock'], setupFilesAfterEnv: [ diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.spec.js b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js new file mode 100644 index 000000000..69e323a0e --- /dev/null +++ b/src/components/MessagesList/MessagesGroup/Message/Message.spec.js @@ -0,0 +1,1060 @@ +import Vuex from 'vuex' +import { createLocalVue, shallowMount } from '@vue/test-utils' +import { cloneDeep } from 'lodash' +import { EventBus } from '../../../../services/EventBus' +import storeConfig from '../../../../store/storeConfig' +import { CONVERSATION, PARTICIPANT, ATTENDEE } from '../../../../constants' +import Check from 'vue-material-design-icons/Check' +import CheckAll from 'vue-material-design-icons/CheckAll' +import Quote from '../../../Quote' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import Mention from './MessagePart/Mention' +import FilePreview from './MessagePart/FilePreview' +import DeckCard from './MessagePart/DeckCard' +import DefaultParameter from './MessagePart/DefaultParameter' + +import Message from './Message' + +// needed because of https://github.com/vuejs/vue-test-utils/issues/1507 +const RichTextStub = { + props: { + text: { + type: String, + }, + arguments: { + type: Object, + }, + }, + template: '<div/>', +} + +describe('Message.vue', () => { + const TOKEN = 'XXTOKENXX' + let localVue + let testStoreConfig + let store + let messageProps + let conversationProps + let getActorTypeMock + + beforeEach(() => { + localVue = createLocalVue() + localVue.use(Vuex) + + conversationProps = { + token: TOKEN, + lastCommonReadMessage: 0, + type: CONVERSATION.TYPE.GROUP, + readOnly: CONVERSATION.STATE.READ_WRITE, + } + + testStoreConfig = cloneDeep(storeConfig) + testStoreConfig.modules.tokenStore.getters.getToken + = jest.fn().mockReturnValue(() => TOKEN) + testStoreConfig.modules.conversationsStore.getters.conversation + = jest.fn().mockReturnValue((token) => conversationProps) + testStoreConfig.modules.actorStore.getters.getActorId + = jest.fn().mockReturnValue(() => 'user-id-1') + getActorTypeMock = jest.fn().mockReturnValue(() => ATTENDEE.ACTOR_TYPE.USERS) + testStoreConfig.modules.actorStore.getters.getActorType = getActorTypeMock + + messageProps = { + message: 'test message', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + actorId: 'user-id-1', + actorDisplayName: 'user-display-name-1', + messageParameters: {}, + id: 123, + isTemporary: false, + isFirstMessage: true, + isReplyable: true, + timestamp: new Date('2020-05-07 09:23:00').getTime() / 1000, + token: TOKEN, + systemMessage: '', + messageType: 'comment', + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('message rendering', () => { + beforeEach(() => { + store = new Vuex.Store(testStoreConfig) + }) + + test('renders rich text message', async() => { + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const message = wrapper.findComponent({ name: 'RichText' }) + expect(message.attributes('text')).toBe('test message') + }) + + test('renders emoji as single plain text', async() => { + messageProps.isSingleEmoji = true + messageProps.message = '🌧️' + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const emoji = wrapper.find('.message-body__main__text') + expect(emoji.text()).toBe('🌧️') + + const message = wrapper.findComponent({ name: 'RichText' }) + expect(message.exists()).toBe(false) + }) + + describe('call button', () => { + beforeEach(() => { + testStoreConfig.modules.messagesStore.getters.messagesList = jest.fn().mockReturnValue((token) => { + return [{ + id: 1, + systemMessage: 'call_started', + message: 'message one', + }, { + id: 2, + systemMessage: 'call_started', + message: 'message two', + }] + }) + store = new Vuex.Store(testStoreConfig) + }) + + test('shows join call button on last message when a call is in progress', () => { + messageProps.id = 2 + messageProps.systemMessage = 'call_started' + messageProps.message = 'message two' + conversationProps.hasCall = true + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const richText = wrapper.findComponent({ name: 'RichText' }) + expect(richText.attributes('text')).toBe('message two') + + const callButton = wrapper.findComponent({ name: 'CallButton' }) + expect(callButton.exists()).toBe(true) + }) + + test('does not show join call button on non-last message when a call is in progress', () => { + messageProps.id = 1 + messageProps.systemMessage = 'call_started' + messageProps.message = 'message one' + conversationProps.hasCall = true + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const callButton = wrapper.findComponent({ name: 'CallButton' }) + expect(callButton.exists()).toBe(false) + }) + + test('does not show join call button when no call is in progress', () => { + messageProps.id = 2 + messageProps.systemMessage = 'call_started' + messageProps.message = 'message two' + conversationProps.hasCall = false + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const callButton = wrapper.findComponent({ name: 'CallButton' }) + expect(callButton.exists()).toBe(false) + }) + + test('does not show join call button when self is in call', () => { + messageProps.id = 2 + messageProps.systemMessage = 'call_started' + messageProps.message = 'message two' + conversationProps.hasCall = true + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + mixins: [{ + // mock the isInCall mixin + computed: { + isInCall: () => true, + }, + }], + }) + + const callButton = wrapper.findComponent({ name: 'CallButton' }) + expect(callButton.exists()).toBe(false) + }) + }) + + test('renders deleted system message', () => { + messageProps.systemMessage = 'comment_deleted' + messageProps.message = 'message deleted' + conversationProps.hasCall = true + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + mixins: [{ + // mock the isInCall mixin + computed: { + isInCall: () => true, + }, + }], + }) + + const richText = wrapper.findComponent({ name: 'RichText' }) + expect(richText.attributes('text')).toBe('message deleted') + }) + + test('renders date', () => { + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const date = wrapper.find('.date') + expect(date.exists()).toBe(true) + expect(date.text()).toBe('09:23') + }) + + test('renders quote block', () => { + const parentMessage = { + id: 120, + message: 'quoted text', + actorId: 'another-user', + actorDisplayName: 'anotherUser', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + messageParameters: {}, + token: TOKEN, + parentId: -1, + } + messageProps.parent = 120 + + const messageGetterMock = jest.fn().mockReturnValue(parentMessage) + testStoreConfig.modules.messagesStore.getters.message = jest.fn(() => messageGetterMock) + store = new Vuex.Store(testStoreConfig) + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + // parent message got queried from the store + expect(messageGetterMock).toHaveBeenCalledWith(TOKEN, 120) + + const quote = wrapper.findComponent(Quote) + expect(quote.exists()).toBe(true) + expect(quote.attributes('message')).toBe('quoted text') + }) + + describe('rich objects', () => { + function renderRichObject(message, messageParameters, expectedRichParameters) { + messageProps.message = message + messageProps.messageParameters = messageParameters + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + RichText: RichTextStub, + }, + propsData: messageProps, + }) + + const messageEl = wrapper.findComponent({ name: 'RichText' }) + // note: indices as object keys are on purpose + expect(messageEl.props('arguments')).toStrictEqual(expectedRichParameters) + } + + test('renders mentions', () => { + const mentions = { + 'mention-user1': { + id: 'alice', + name: 'Alice', + type: 'user', + }, + 'mention-user2': { + id: 'bob', + name: 'Bob', + type: 'guest', + }, + 'mention-call1': { + id: 'some_call', + type: 'call', + }, + } + renderRichObject( + 'hello {mention-user1}, {mention-user2} wants to have a {mention-call1} with you', + mentions, + { + 'mention-user1': { + component: Mention, + props: mentions['mention-user1'], + }, + 'mention-user2': { + component: Mention, + props: mentions['mention-user2'], + }, + 'mention-call1': { + component: Mention, + props: mentions['mention-call1'], + }, + } + ) + }) + + test('renders file previews', () => { + const params = { + 'actor': { + id: 'alice', + name: 'Alice', + type: 'user', + }, + 'file': { + path: 'some/path', + type: 'file', + }, + } + renderRichObject( + '{file}', + params, { + 'actor': { + component: Mention, + props: params['actor'], + }, + 'file': { + component: FilePreview, + props: params['file'], + }, + } + ) + }) + + test('renders deck cards', () => { + const params = { + 'actor': { + id: 'alice', + name: 'Alice', + type: 'user', + }, + 'deck-card': { + metadata: '{id:123}', + type: 'deck-card', + }, + } + renderRichObject( + '{deck-card}', + params, { + 'actor': { + component: Mention, + props: params['actor'], + }, + 'deck-card': { + component: DeckCard, + props: params['deck-card'], + }, + } + ) + }) + + test('renders other rich objects', () => { + const params = { + 'actor': { + id: 'alice', + name: 'Alice', + type: 'user', + }, + 'unknown': { + path: 'some/path', + type: 'unknown', + }, + } + renderRichObject( + '{unknown}', + params, { + 'actor': { + component: Mention, + props: params['actor'], + }, + 'unknown': { + component: DefaultParameter, + props: params['unknown'], + }, + } + ) + }) + }) + + test('displays unread message marker that marks the message seen when visible', () => { + messageProps.lastReadMessageId = 123 + const observeVisibility = jest.fn() + + const wrapper = shallowMount(Message, { + localVue, + store, + directives: { + observeVisibility, + }, + propsData: messageProps, + }) + + const marker = wrapper.find('.new-message-marker') + expect(marker.exists()).toBe(true) + + expect(observeVisibility).toHaveBeenCalled() + const directiveValue = observeVisibility.mock.calls[0][1] + + expect(wrapper.vm.seen).toEqual(false) + + directiveValue.value(false) + expect(wrapper.vm.seen).toEqual(false) + + directiveValue.value(true) + expect(wrapper.vm.seen).toEqual(true) + + // stays true if it was visible once + directiveValue.value(false) + expect(wrapper.vm.seen).toEqual(true) + }) + }) + + describe('author rendering', () => { + const AUTHOR_SELECTOR = '.message-body__author' + beforeEach(() => { + store = new Vuex.Store(testStoreConfig) + }) + + test('renders author if first message', async() => { + messageProps.isFirstMessage = true + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const displayName = wrapper.find(AUTHOR_SELECTOR) + expect(displayName.text()).toBe('user-display-name-1') + }) + + test('does not render author if not first message', async() => { + messageProps.isFirstMessage = false + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const displayName = wrapper.find(AUTHOR_SELECTOR) + expect(displayName.exists()).toBe(false) + }) + }) + + describe('actions', () => { + const ACTIONS_SELECTOR = '.message-body__main__right__actions' + + beforeEach(() => { + store = new Vuex.Store(testStoreConfig) + }) + + function findActionButton(wrapper, text) { + const actionButtons = wrapper.findAllComponents({ name: 'ActionButton' }) + const items = actionButtons.filter(actionButton => { + return actionButton.text() === text + }) + if (!items.exists()) { + return items + } + return items.at(0) + } + + test('does not render actions for system messages are available', async() => { + messageProps.systemMessage = 'this is a system message' + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + await wrapper.vm.$nextTick() + + const actionsEl = wrapper.find(ACTIONS_SELECTOR) + expect(actionsEl.exists()).toBe(false) + }) + + test('does not render actions for temporary messages', async() => { + messageProps.isTemporary = true + + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + await wrapper.vm.$nextTick() + + const actionsEl = wrapper.find(ACTIONS_SELECTOR) + expect(actionsEl.exists()).toBe(false) + }) + + test('actions become visible on mouse over', async() => { + messageProps.sendingFailure = 'timeout' + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + await wrapper.vm.$nextTick() + + const actionsEl = wrapper.find(ACTIONS_SELECTOR) + + expect(wrapper.vm.showActions).toBe(false) + expect(actionsEl.isVisible()).toBe(false) + + await wrapper.find('.message-body').trigger('mouseover') + + expect(wrapper.vm.showActions).toBe(true) + expect(actionsEl.isVisible()).toBe(true) + + await wrapper.find('.message-body').trigger('mouseleave') + + expect(wrapper.vm.showActions).toBe(false) + expect(actionsEl.isVisible()).toBe(false) + + // actions are always present and rendered + const actions = wrapper.findAllComponents({ name: 'Actions' }) + expect(actions.length).toBe(2) + }) + + describe('reply action', () => { + test('replies to message', async() => { + const replyAction = jest.fn() + testStoreConfig.modules.quoteReplyStore.actions.addMessageToBeReplied = replyAction + store = new Vuex.Store(testStoreConfig) + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + await wrapper.find('.message-body').trigger('mouseover') + const actionButton = findActionButton(wrapper, 'Reply') + expect(actionButton.exists()).toBe(true) + expect(actionButton.isVisible()).toBe(true) + await actionButton.find('button').trigger('click') + + expect(replyAction).toHaveBeenCalledWith(expect.anything(), { + id: 123, + actorId: 'user-id-1', + actorType: 'users', + actorDisplayName: 'user-display-name-1', + message: 'test message', + messageParameters: {}, + messageType: 'comment', + systemMessage: '', + timestamp: new Date('2020-05-07 09:23:00').getTime() / 1000, + token: TOKEN, + }) + }) + + test('hides reply button when not replyable', async() => { + messageProps.isReplyable = false + store = new Vuex.Store(testStoreConfig) + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Reply') + expect(actionButton.isVisible()).toBe(false) + }) + }) + + describe('private reply action', () => { + test('creates a new conversation when replying to message privately', async() => { + const routerPushMock = jest.fn().mockResolvedValue() + const createOneToOneConversation = jest.fn() + testStoreConfig.modules.conversationsStore.actions.createOneToOneConversation = createOneToOneConversation + store = new Vuex.Store(testStoreConfig) + + messageProps.actorId = 'another-user' + + const wrapper = shallowMount(Message, { + localVue, + store, + mocks: { + $router: { + push: routerPushMock, + }, + }, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Reply privately') + expect(actionButton.exists()).toBe(true) + + createOneToOneConversation.mockResolvedValueOnce({ + token: 'new-token', + }) + + await actionButton.find('button').trigger('click') + + expect(createOneToOneConversation).toHaveBeenCalledWith(expect.anything(), 'another-user') + + expect(routerPushMock).toHaveBeenCalledWith({ + name: 'conversation', + params: { + token: 'new-token', + }, + }) + }) + + function testPrivateReplyActionVisible(visible) { + store = new Vuex.Store(testStoreConfig) + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Reply privately') + expect(actionButton.exists()).toBe(visible) + } + + test('hides private reply action for own messages', async() => { + // using default message props which have the + // actor id set to the current user + testPrivateReplyActionVisible(false) + }) + + test('hides private reply action for one to one conversation type', async() => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE + testPrivateReplyActionVisible(false) + }) + + test('hides private reply action for guest messages', async() => { + messageProps.actorId = 'guest-user' + messageProps.actorType = ATTENDEE.ACTOR_TYPE.GUESTS + testPrivateReplyActionVisible(false) + }) + + test('hides private reply action when current user is a guest', async() => { + messageProps.actorId = 'another-user' + getActorTypeMock.mockClear().mockReturnValue(() => ATTENDEE.ACTOR_TYPE.GUESTS) + testPrivateReplyActionVisible(false) + }) + }) + + describe('delete action', () => { + test('deletes message', async() => { + let resolveDeleteMessage + const deleteMessage = jest.fn().mockReturnValue(new Promise((resolve, reject) => { resolveDeleteMessage = resolve })) + testStoreConfig.modules.messagesStore.actions.deleteMessage = deleteMessage + store = new Vuex.Store(testStoreConfig) + + // need to mock the date to be within 6h + const mockDate = new Date('2020-05-07 10:00:00') + jest.spyOn(global.Date, 'now') + .mockImplementation(() => mockDate) + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Delete') + expect(actionButton.exists()).toBe(true) + + await actionButton.find('button').trigger('click') + + expect(deleteMessage).toHaveBeenCalledWith(expect.anything(), { + message: { + token: TOKEN, + id: 123, + }, + placeholder: expect.anything(), + }) + + await wrapper.vm.$nextTick() + expect(wrapper.vm.isDeleting).toBe(true) + expect(wrapper.find('.icon-loading-small').exists()).toBe(true) + + resolveDeleteMessage(200) + // needs two updates... + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(wrapper.vm.isDeleting).toBe(false) + expect(wrapper.find('.icon-loading-small').exists()).toBe(false) + }) + + function testDeleteMessageVisible(visible, mockDate, participantType = PARTICIPANT.TYPE.USER) { + store = new Vuex.Store(testStoreConfig) + + // need to mock the date to be within 6h + if (!mockDate) { + mockDate = new Date('2020-05-07 10:00:00') + } + + jest.spyOn(global.Date, 'now') + .mockImplementation(() => mockDate) + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'user-id-1', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + participantType, + } + }, + }, + }], + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Delete') + expect(actionButton.exists()).toBe(visible) + } + + test('hides delete action when message is older than 6 hours', () => { + testDeleteMessageVisible(false, new Date('2020-05-07 15:24:00')) + }) + + test('hides delete action when the conversation is read-only', () => { + conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY + testDeleteMessageVisible(false) + }) + + test('hides delete action for file messages', () => { + messageProps.message = '{file}' + messageProps.messageParameters.file = {} + testDeleteMessageVisible(false) + }) + + test('hides delete action on other people messages for non-moderators', () => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.GROUP + testDeleteMessageVisible(false) + }) + + test('shows delete action on other people messages for moderators', () => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.GROUP + testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.MODERATOR) + }) + + test('shows delete action on other people messages for owner', () => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.PUBLIC + testDeleteMessageVisible(true, null, PARTICIPANT.TYPE.OWNER) + }) + + test('does not show delete action even for guest moderators', () => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.PUBLIC + testDeleteMessageVisible(false, null, PARTICIPANT.TYPE.GUEST_MODERATOR) + }) + + test('does not show delete action on other people messages in one to one conversations', () => { + messageProps.actorId = 'another-user' + conversationProps.type = CONVERSATION.TYPE.ONE_TO_ONE + testDeleteMessageVisible(false) + }) + }) + + test('marks message as unread', async() => { + const updateLastReadMessageAction = jest.fn().mockResolvedValueOnce() + const fetchConversationAction = jest.fn().mockResolvedValueOnce() + testStoreConfig.modules.conversationsStore.actions.updateLastReadMessage = updateLastReadMessageAction + testStoreConfig.modules.conversationsStore.actions.fetchConversation = fetchConversationAction + store = new Vuex.Store(testStoreConfig) + + messageProps.previousMessageId = 100 + + // appears even with more restrictive conditions + conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY + messageProps.actorId = 'another-user' + + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'guest-id-1', + actorType: ATTENDEE.ACTOR_TYPE.GUESTS, + participantType: PARTICIPANT.TYPE.GUEST, + } + }, + }, + }], + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Mark as unread') + expect(actionButton.exists()).toBe(true) + + await actionButton.find('button').trigger('click') + // needs two updates... + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(updateLastReadMessageAction).toHaveBeenCalledWith(expect.anything(), { + token: TOKEN, + id: 100, + updateVisually: true, + }) + + expect(fetchConversationAction).toHaveBeenCalledWith(expect.anything(), { + token: TOKEN, + }) + }) + + test('copies message link', async() => { + const copyTextMock = jest.fn() + + // appears even with more restrictive conditions + conversationProps.readOnly = CONVERSATION.STATE.READ_ONLY + messageProps.actorId = 'another-user' + + const wrapper = shallowMount(Message, { + localVue, + store, + mocks: { + $copyText: copyTextMock, + }, + stubs: { + ActionButton, + }, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'guest-id-1', + actorType: ATTENDEE.ACTOR_TYPE.GUESTS, + participantType: PARTICIPANT.TYPE.GUEST, + } + }, + }, + }], + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'Copy message link') + expect(actionButton.exists()).toBe(true) + + await actionButton.find('button').trigger('click') + + expect(copyTextMock).toHaveBeenCalledWith('http://localhost/nc-webroot/call/XXTOKENXX#message_123') + }) + + test('renders clickable custom actions', async() => { + const handler = jest.fn() + const handler2 = jest.fn() + const actionsGetterMock = jest.fn().mockReturnValue([{ + label: 'first action', + icon: 'some-icon', + callback: handler, + }, { + label: 'second action', + icon: 'some-icon2', + callback: handler2, + }]) + testStoreConfig.modules.messageActionsStore.getters.messageActions = actionsGetterMock + testStoreConfig.modules.messagesStore.getters.message = jest.fn(() => () => messageProps) + store = new Vuex.Store(testStoreConfig) + const wrapper = shallowMount(Message, { + localVue, + store, + stubs: { + ActionButton, + }, + propsData: messageProps, + }) + + const actionButton = findActionButton(wrapper, 'first action') + expect(actionButton.exists()).toBe(true) + await actionButton.find('button').trigger('click') + + expect(handler).toHaveBeenCalledWith({ + apiVersion: 'v3', + message: messageProps, + metadata: conversationProps, + }) + + const actionButton2 = findActionButton(wrapper, 'second action') + expect(actionButton2.exists()).toBe(true) + await actionButton2.find('button').trigger('click') + + expect(handler2).toHaveBeenCalledWith({ + apiVersion: 'v3', + message: messageProps, + metadata: conversationProps, + }) + }) + }) + + describe('status', () => { + beforeEach(() => { + store = new Vuex.Store(testStoreConfig) + }) + + test('lets user retry sending a timed out message', async() => { + messageProps.sendingFailure = 'timeout' + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + await wrapper.find('.message-body').trigger('mouseover') + + const reloadButton = wrapper.find('.sending-failed') + expect(reloadButton.exists()).toBe(true) + + await reloadButton.trigger('mouseover') + + expect(wrapper.vm.showReloadButton).toBe(true) + + const reloadButtonIcon = reloadButton.find('button') + expect(reloadButtonIcon.exists()).toBe(true) + + const retryEvent = jest.fn() + EventBus.$on('retryMessage', retryEvent) + + await reloadButtonIcon.trigger('click') + + expect(retryEvent).toHaveBeenCalledWith(123) + }) + + test('displays the message already with a spinner while sending it', () => { + messageProps.isTemporary = true + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + }) + + const message = wrapper.findComponent({ name: 'RichText' }) + expect(message.attributes('text')).toBe('test message') + + expect(wrapper.find('.icon-loading-small').exists()).toBe(true) + }) + + test('displays icon when message was read by everyone', () => { + conversationProps.lastCommonReadMessage = 123 + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'user-id-1', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + } + }, + }, + }], + }) + + expect(wrapper.findComponent(Check).exists()).toBe(false) + expect(wrapper.findComponent(CheckAll).exists()).toBe(true) + }) + + test('displays sent icon when own message was sent', () => { + conversationProps.lastCommonReadMessage = 0 + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'user-id-1', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + } + }, + }, + }], + }) + + expect(wrapper.findComponent(Check).exists()).toBe(true) + expect(wrapper.findComponent(CheckAll).exists()).toBe(false) + }) + + test('does not displays check icon for other people\'s messages', () => { + conversationProps.lastCommonReadMessage = 123 + const wrapper = shallowMount(Message, { + localVue, + store, + propsData: messageProps, + mixins: [{ + computed: { + participant: () => { + return { + actorId: 'user-id-2', + actorType: ATTENDEE.ACTOR_TYPE.USERS, + } + }, + }, + }], + }) + + expect(wrapper.findComponent(Check).exists()).toBe(false) + expect(wrapper.findComponent(CheckAll).exists()).toBe(false) + }) + }) +}) diff --git a/src/components/MessagesList/MessagesGroup/Message/Message.vue b/src/components/MessagesList/MessagesGroup/Message/Message.vue index 0f40ffaef..ee90753f7 100644 --- a/src/components/MessagesList/MessagesGroup/Message/Message.vue +++ b/src/components/MessagesList/MessagesGroup/Message/Message.vue @@ -329,7 +329,7 @@ export default { required: true, }, /** - * The conversation token. + * The type of system message */ systemMessage: { type: String, @@ -442,7 +442,9 @@ export default { }, isLastCallStartedMessage() { + // FIXME: remove dependency to messages list and convert to property const messages = this.messagesList + // FIXME: don't reverse the whole array as it would create a copy, just do an actual reverse search const lastCallStartedMessage = messages.reverse().find((message) => message.systemMessage === 'call_started') return lastCallStartedMessage ? (this.id === lastCallStartedMessage.id) : false }, @@ -708,6 +710,7 @@ export default { await this.$copyText(link) showSuccess(t('spreed', 'Message link copied to clipboard.')) } catch (error) { + console.error('Error copying link: ', error) showError(t('spreed', 'The link could not be copied.')) } }, diff --git a/src/test-setup.js b/src/test-setup.js index 21cdad62f..9854337dd 100644 --- a/src/test-setup.js +++ b/src/test-setup.js @@ -27,6 +27,9 @@ import Vue from 'vue' global.OC = { requestToken: '123', webroot: '/nc-webroot', + config: { + modRewriteWorking: true, + }, isUserAdmin() { return true }, |