Compare commits

..
Author SHA1 Message Date
Sojan Jose 6af34335a8 test: add spec for profile access token reset 2025-05-23 02:22:16 -07:00
22 changed files with 178 additions and 148 deletions
+40 -40
View File
@@ -99,17 +99,17 @@ jobs:
chmod +x ~/tmp/cc-test-reporter chmod +x ~/tmp/cc-test-reporter
# Swagger verification # Swagger verification
# - run: - run:
# name: Verify swagger API specification name: Verify swagger API specification
# command: | command: |
# bundle exec rake swagger:build bundle exec rake swagger:build
# if [[ `git status swagger/swagger.json --porcelain` ]] if [[ `git status swagger/swagger.json --porcelain` ]]
# then then
# echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
# exit 1 exit 1
# fi fi
# curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
# java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
# we remove the FRONTED_URL from the .env before running the tests # we remove the FRONTED_URL from the .env before running the tests
- run: - run:
@@ -139,26 +139,26 @@ jobs:
command: bundle exec bundle audit update && bundle exec bundle audit check -v command: bundle exec bundle audit update && bundle exec bundle audit check -v
# Rubocop linting # Rubocop linting
# - run: - run:
# name: Rubocop name: Rubocop
# command: bundle exec rubocop command: bundle exec rubocop
# # ESLint linting # ESLint linting
# - run: - run:
# name: eslint name: eslint
# command: pnpm run eslint command: pnpm run eslint
# - run: - run:
# name: Run frontend tests name: Run frontend tests
# command: | command: |
# mkdir -p ~/build/coverage/frontend mkdir -p ~/build/coverage/frontend
# ~/tmp/cc-test-reporter before-build ~/tmp/cc-test-reporter before-build
# pnpm run test:coverage pnpm run test:coverage
# - run: - run:
# name: Code Climate Test Coverage (Frontend) name: Code Climate Test Coverage (Frontend)
# command: | command: |
# ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
# Run backend tests # Run backend tests
- run: - run:
@@ -175,17 +175,17 @@ jobs:
-- ${TESTFILES} -- ${TESTFILES}
no_output_timeout: 30m no_output_timeout: 30m
# - run: - run:
# name: Code Climate Test Coverage (Backend) name: Code Climate Test Coverage (Backend)
# command: | command: |
# ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json" ~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
# - run: - run:
# name: List coverage directory contents name: List coverage directory contents
# command: | command: |
# ls -R ~/build/coverage ls -R ~/build/coverage
# - persist_to_workspace: - persist_to_workspace:
# root: ~/build root: ~/build
# paths: paths:
# - coverage - coverage
@@ -38,6 +38,11 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok head :ok
end end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private private
def set_user def set_user
+4
View File
@@ -102,4 +102,8 @@ export default {
const urlData = endPoints('resendConfirmation'); const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url); return axios.post(urlData.url);
}, },
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
}; };
@@ -51,6 +51,9 @@ const endPoints = {
resendConfirmation: { resendConfirmation: {
url: '/api/v1/profile/resend_confirmation', url: '/api/v1/profile/resend_confirmation',
}, },
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
}; };
export default page => { export default page => {
@@ -315,7 +315,11 @@ const componentToRender = computed(() => {
}); });
const shouldShowContextMenu = computed(() => { const shouldShowContextMenu = computed(() => {
return !props.contentAttributes?.isUnsupported; return !(
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
}); });
const isBubble = computed(() => { const isBubble = computed(() => {
@@ -340,23 +344,12 @@ const contextMenuEnabledOptions = computed(() => {
const hasAttachments = !!(props.attachments && props.attachments.length > 0); const hasAttachments = !!(props.attachments && props.attachments.length > 0);
const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING; const isOutgoing = props.messageType === MESSAGE_TYPES.OUTGOING;
const isFailedOrProcessing =
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS;
return { return {
copy: hasText, copy: hasText,
delete: delete: hasText || hasAttachments,
(hasText || hasAttachments) && cannedResponse: isOutgoing && hasText,
!isFailedOrProcessing && replyTo: !props.private && props.inboxSupportsReplyTo.outgoing,
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
}; };
}); });
@@ -506,8 +499,8 @@ provideMessageContext({
<div <div
class="[grid-area:bubble] flex" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT, 'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL, 'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
@@ -523,7 +516,7 @@ provideMessageContext({
</div> </div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap"> <div v-if="shouldShowContextMenu" class="context-menu-wrap">
<ContextMenu <ContextMenu
v-if="isBubble" v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition" :context-menu-position="contextMenuPosition"
:is-open="showContextMenu" :is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions" :enabled-options="contextMenuEnabledOptions"
@@ -8,6 +8,7 @@ import {
computed, computed,
watch, watch,
onMounted, onMounted,
onUnmounted,
defineEmits, defineEmits,
} from 'vue'; } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
@@ -43,7 +44,7 @@ import {
useSnakeCase, useSnakeCase,
} from 'dashboard/composables/useTransformKeys'; } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter'; import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core'; import { useEventListener, useScrollLock } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -86,8 +87,12 @@ const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null); const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
provide('contextMenuElementTarget', conversationDynamicScroller); () => conversationDynamicScroller.value?.$el
);
const conversationListScrollLock = useScrollLock(
conversationListScrollableElement
);
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME); const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN); const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
@@ -741,6 +746,7 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) { function onContextMenuToggle(state) {
isContextMenuOpen.value = state; isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
} }
function toggleSelectAll(check) { function toggleSelectAll(check) {
@@ -764,6 +770,10 @@ onMounted(() => {
} }
}); });
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@@ -1,13 +1,6 @@
<script setup> <script setup>
import { import { computed, onMounted, nextTick, useTemplateRef } from 'vue';
computed, import { useWindowSize, useElementBounding } from '@vueuse/core';
onMounted,
nextTick,
onUnmounted,
useTemplateRef,
inject,
} from 'vue';
import { useWindowSize, useElementBounding, useScrollLock } from '@vueuse/core';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue'; import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
@@ -18,34 +11,27 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const elementToLock = inject('contextMenuElementTarget', null);
const menuRef = useTemplateRef('menuRef'); const menuRef = useTemplateRef('menuRef');
const scrollLockElement = computed(() => {
if (!elementToLock?.value) return null;
return elementToLock.value?.$el;
});
const isLocked = useScrollLock(scrollLockElement);
const { width: windowWidth, height: windowHeight } = useWindowSize(); const { width: windowWidth, height: windowHeight } = useWindowSize();
const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef); const { width: menuWidth, height: menuHeight } = useElementBounding(menuRef);
const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => { const calculatePosition = (x, y, menuW, menuH, windowW, windowH) => {
const PADDING = 16;
// Initial position // Initial position
let left = x; let left = x;
let top = y; let top = y;
// Boundary checks // Boundary checks
const isOverflowingRight = left + menuW > windowW - PADDING; const isOverflowingRight = left + menuW > windowW;
const isOverflowingBottom = top + menuH > windowH - PADDING; const isOverflowingBottom = top + menuH > windowH;
// Adjust position if overflowing // Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW - PADDING; if (isOverflowingRight) left = windowW - menuW;
if (isOverflowingBottom) top = windowH - menuH - PADDING; if (isOverflowingBottom) top = windowH - menuH;
return { return {
left: Math.max(PADDING, left), left: Math.max(0, left),
top: Math.max(PADDING, top), top: Math.max(0, top),
}; };
}; };
@@ -68,18 +54,8 @@ const position = computed(() => {
}); });
onMounted(() => { onMounted(() => {
isLocked.value = true;
nextTick(() => menuRef.value?.focus()); nextTick(() => menuRef.value?.focus());
}); });
const handleClose = () => {
isLocked.value = false;
emit('close');
};
onUnmounted(() => {
isLocked.value = false;
});
</script> </script>
<template> <template>
@@ -89,7 +65,7 @@ onUnmounted(() => {
class="fixed outline-none z-[9999] cursor-pointer" class="fixed outline-none z-[9999] cursor-pointer"
:style="position" :style="position"
tabindex="0" tabindex="0"
@blur="handleClose" @blur="emit('close')"
> >
<slot /> <slot />
</div> </div>
@@ -185,17 +185,8 @@ export default {
contextMenuEnabledOptions() { contextMenuEnabledOptions() {
return { return {
copy: this.hasText, copy: this.hasText,
delete: delete: this.hasText || this.hasAttachments,
(this.hasText || this.hasAttachments) && cannedResponse: this.isOutgoing && this.hasText,
!this.isMessageDeleted &&
!this.isFailed,
cannedResponse:
this.isOutgoing && this.hasText && !this.isMessageDeleted,
copyLink: !this.isFailed || !this.isProcessing,
translate:
(!this.isFailed || !this.isProcessing) &&
!this.isMessageDeleted &&
this.hasText,
replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing, replyTo: !this.data.private && this.inboxSupportsReplyTo.outgoing,
}; };
}, },
@@ -337,7 +328,7 @@ export default {
return !this.sender.type || this.sender.type === 'agent_bot'; return !this.sender.type || this.sender.type === 'agent_bot';
}, },
shouldShowContextMenu() { shouldShowContextMenu() {
return !this.isUnsupported; return !(this.isFailed || this.isPending || this.isUnsupported);
}, },
showAvatar() { showAvatar() {
if (this.isOutgoing || this.isTemplate) { if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script> <script>
import { ref, provide } from 'vue'; import { ref } from 'vue';
// composable // composable
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
@@ -63,7 +63,6 @@ export default {
emits: ['contactPanelToggle'], emits: ['contactPanelToggle'],
setup() { setup() {
const isPopOutReplyBox = ref(false); const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig(); const { isEnterprise } = useConfig();
const closePopOutReplyBox = () => { const closePopOutReplyBox = () => {
@@ -99,8 +98,6 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4 FEATURE_FLAGS.CHATWOOT_V4
); );
provide('contextMenuElementTarget', conversationPanelRef);
return { return {
isEnterprise, isEnterprise,
isPopOutReplyBox, isPopOutReplyBox,
@@ -111,7 +108,6 @@ export default {
fetchIntegrationsIfRequired, fetchIntegrationsIfRequired,
fetchLabelSuggestions, fetchLabelSuggestions,
showNextBubbles, showNextBubbles,
conversationPanelRef,
}; };
}, },
data() { data() {
@@ -545,7 +541,6 @@ export default {
</div> </div>
<NextMessageList <NextMessageList
v-if="showNextBubbles" v-if="showNextBubbles"
ref="conversationPanelRef"
class="conversation-panel" class="conversation-panel"
:current-user-id="currentUserId" :current-user-id="currentUserId"
:first-unread-id="unReadMessages[0]?.id" :first-unread-id="unReadMessages[0]?.id"
@@ -577,7 +572,7 @@ export default {
/> />
</template> </template>
</NextMessageList> </NextMessageList>
<ul v-else ref="conversationPanelRef" class="conversation-panel"> <ul v-else class="conversation-panel">
<transition name="slide-up"> <transition name="slide-up">
<!-- eslint-disable-next-line vue/require-toggle-inside-transition --> <!-- eslint-disable-next-line vue/require-toggle-inside-transition -->
<li class="min-h-[4rem]"> <li class="min-h-[4rem]">
@@ -76,7 +76,10 @@
"ACCESS_TOKEN": { "ACCESS_TOKEN": {
"TITLE": "Access Token", "TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration", "NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy" "COPY": "Copy",
"RESET": "Reset",
"RESET_SUCCESS": "Access token regenerated",
"RESET_ERROR": "Could not reset token"
}, },
"AUDIO_NOTIFICATIONS_SECTION": { "AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts", "TITLE": "Audio Alerts",
@@ -47,7 +47,6 @@ export default {
emits: ['open', 'close', 'replyTo'], emits: ['open', 'close', 'replyTo'],
setup() { setup() {
const { getPlainText } = useMessageFormatter(); const { getPlainText } = useMessageFormatter();
return { return {
getPlainText, getPlainText,
}; };
@@ -168,7 +167,7 @@ export default {
</woot-modal> </woot-modal>
<!-- Confirm Deletion --> <!-- Confirm Deletion -->
<woot-delete-modal <woot-delete-modal
v-if="showDeleteModal && enabledOptions['delete']" v-if="showDeleteModal"
v-model:show="showDeleteModal" v-model:show="showDeleteModal"
class="context-menu--delete-modal" class="context-menu--delete-modal"
:on-close="closeDeleteModal" :on-close="closeDeleteModal"
@@ -213,7 +212,7 @@ export default {
@click.stop="handleCopy" @click.stop="handleCopy"
/> />
<MenuItem <MenuItem
v-if="enabledOptions['translate']" v-if="enabledOptions['copy']"
:option="{ :option="{
icon: 'translate', icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'), label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -223,7 +222,6 @@ export default {
/> />
<hr /> <hr />
<MenuItem <MenuItem
v-if="enabledOptions['copyLink']"
:option="{ :option="{
icon: 'link', icon: 'link',
label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'), label: $t('CONVERSATION.CONTEXT_MENU.COPY_PERMALINK'),
@@ -7,7 +7,7 @@ const props = defineProps({
default: '', default: '',
}, },
}); });
const emit = defineEmits(['onCopy']); const emit = defineEmits(['onCopy', 'onReset']);
const inputType = ref('password'); const inputType = ref('password');
const toggleMasked = () => { const toggleMasked = () => {
inputType.value = inputType.value === 'password' ? 'text' : 'password'; inputType.value = inputType.value === 'password' ? 'text' : 'password';
@@ -20,6 +20,10 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -56,5 +60,15 @@ const onClick = () => {
> >
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }} {{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.COPY') }}
</FormButton> </FormButton>
<FormButton
type="button"
size="large"
icon="key"
variant="outline"
color-scheme="primary"
@click="onReset"
>
{{ $t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET') }}
</FormButton>
</div> </div>
</template> </template>
@@ -181,6 +181,14 @@ export default {
await copyTextToClipboard(value); await copyTextToClipboard(value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
async resetAccessToken() {
const success = await this.$store.dispatch('resetAccessToken');
if (success) {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_SUCCESS'));
} else {
useAlert(this.$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.RESET_ERROR'));
}
},
}, },
}; };
</script> </script>
@@ -281,7 +289,11 @@ export default {
) )
" "
> >
<AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" /> <AccessToken
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -213,6 +213,16 @@ export const actions = {
} }
}, },
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => { resendConfirmation: async () => {
try { try {
await authAPI.resendConfirmation(); await authAPI.resendConfirmation();
@@ -0,0 +1 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
-3
View File
@@ -16,16 +16,13 @@ development:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_dev') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
test: test:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_test') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'postgres') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', '') %>"
production: production:
<<: *default <<: *default
database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>" database: "<%= ENV.fetch('POSTGRES_DATABASE', 'chatwoot_production') %>"
username: "<%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>" username: "<%= ENV.fetch('POSTGRES_USERNAME', 'chatwoot_prod') %>"
password: "<%= ENV.fetch('POSTGRES_PASSWORD', 'chatwoot_prod') %>"
+1
View File
@@ -295,6 +295,7 @@ Rails.application.routes.draw do
post :auto_offline post :auto_offline
put :set_active_account put :set_active_account
post :resend_confirmation post :resend_confirmation
post :reset_access_token
end end
end end
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!( @copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message][:content], title: copilot_thread_params[:message],
user: Current.user, user: Current.user,
assistant: assistant assistant: assistant
) )
@@ -25,7 +25,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
private private
def ensure_message def ensure_message
return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].blank? return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank?
end end
def assistant def assistant
@@ -33,7 +33,7 @@ class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::
end end
def copilot_thread_params def copilot_thread_params
params.permit(:assistant_id, message: [:content]) params.permit(:message, :assistant_id)
end end
def permitted_params def permitted_params
@@ -296,4 +296,32 @@ RSpec.describe 'Profile API', type: :request do
end end
end end
end end
describe 'POST /api/v1/profile/reset_access_token' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post '/api/v1/profile/reset_access_token'
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'regenerates the access token' do
old_token = agent.access_token.token
post '/api/v1/profile/reset_access_token',
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
agent.reload
json_response = response.parsed_body
expect(json_response['access_token']).to eq(agent.access_token.token)
expect(agent.access_token.token).not_to eq(old_token)
end
end
end
end end
@@ -50,7 +50,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: { content: 'Hello, how can you help me?' }, assistant_id: assistant.id } } let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
context 'when it is an un-authenticated user' do context 'when it is an un-authenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@@ -66,7 +66,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
context 'with invalid params' do context 'with invalid params' do
it 'returns error when message is blank' do it 'returns error when message is blank' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads", post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: '' }, assistant_id: assistant.id }, params: { message: '', assistant_id: assistant.id },
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
@@ -76,7 +76,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
it 'returns error when assistant_id is invalid' do it 'returns error when assistant_id is invalid' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads", post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: { content: 'Hello' }, assistant_id: 0 }, params: { message: 'Hello', assistant_id: 0 },
headers: agent.create_new_auth_token, headers: agent.create_new_auth_token,
as: :json as: :json
@@ -97,13 +97,13 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
thread = CopilotThread.last thread = CopilotThread.last
expect(thread.title).to eq(valid_params[:message][:content]) expect(thread.title).to eq(valid_params[:message])
expect(thread.user_id).to eq(agent.id) expect(thread.user_id).to eq(agent.id)
expect(thread.assistant_id).to eq(assistant.id) expect(thread.assistant_id).to eq(assistant.id)
message = thread.copilot_messages.last message = thread.copilot_messages.last
expect(message.message_type).to eq('user') expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message].stringify_keys) expect(message.message).to eq(valid_params[:message])
end end
end end
end end
@@ -2,9 +2,8 @@ require 'rails_helper'
RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) } let(:assistant) { create(:captain_assistant, account: account) }
let(:service) { described_class.new(assistant, user: user) } let(:service) { described_class.new(assistant) }
describe '#name' do describe '#name' do
it 'returns the correct service name' do it 'returns the correct service name' do
@@ -7,10 +7,6 @@ describe Webhooks::InstagramEventsJob do
stub_request(:post, /graph\.facebook\.com/) stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: '', headers: {})
# Clear Redis mutex key to prevent lock conflicts in parallel tests
mutex_key = format(Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: 'Sender-id-1', ig_account_id: 'chatwoot-app-user-id-1')
Redis::Alfred.delete(mutex_key)
end end
let!(:account) { create(:account) } let!(:account) { create(:account) }
@@ -188,11 +184,7 @@ describe Webhooks::InstagramEventsJob do
before do before do
instagram_channel.update(access_token: 'valid_instagram_token') instagram_channel.update(access_token: 'valid_instagram_token')
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1') stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*})
.with(query: hash_including(
'fields' => 'name,username,profile_pic,follower_count,is_user_follow_business,is_business_follow_user,is_verified_user',
'access_token' => 'valid_instagram_token'
))
.to_return( .to_return(
status: 200, status: 200,
body: { body: {
@@ -209,7 +201,6 @@ describe Webhooks::InstagramEventsJob do
) )
end end
# skip failing test
it 'creates incoming message with correct contact info in the instagram direct inbox' do it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry]) instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload instagram_inbox.reload
@@ -277,8 +268,7 @@ describe Webhooks::InstagramEventsJob do
end end
it 'does not create contact or messages when Instagram API call fails' do it 'does not create contact or messages when Instagram API call fails' do
stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1') stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.with(query: hash_including('access_token' => 'valid_instagram_token'))
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json) .to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry]) instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])