Compare commits

..
Author SHA1 Message Date
Shivam Mishra e204c2aa4f chore: re-enable test 2025-05-27 18:32:44 +05:30
Shivam Mishra 1acc994d98 Revert "feat: re-enable all ci config"
This reverts commit 58e5f7e4df.
2025-05-27 18:23:22 +05:30
Shivam Mishra 5185bbd34f test: clear mutex before each step 2025-05-27 18:21:31 +05:30
Shivam Mishra 58e5f7e4df feat: re-enable all ci config 2025-05-27 18:19:38 +05:30
Shivam Mishra 7a5b170ff0 fix(ci): cc reporter folder 2025-05-27 18:11:23 +05:30
Shivam Mishra 6c57dbb12d chore(ci): disable frontend items 2025-05-27 18:06:03 +05:30
Shivam Mishra c174ee65e3 feat: run in serial 2025-05-27 17:58:26 +05:30
Shivam Mishra 97b36a868e chore: disable failing test 2025-05-27 17:08:22 +05:30
Shivam Mishra cb8821afbf style: line length 2025-05-27 14:56:35 +05:30
Shivam Mishra cd71a6ec10 fix: better stub path 2025-05-27 14:47:23 +05:30
Shivam Mishra c1044a8b66 fix: accept json directly 2025-05-27 14:42:30 +05:30
Shivam Mishra 6e2a90113c test: fix specs for search linear issue service 2025-05-27 12:35:53 +05:30
Sivin VargheseandGitHub 9bd658137a feat: Scroll lock on message context menu (#11454)
This PR uses `useScrollLock` from `VueUse` to lock scrolling on the
conversation panel when the message context menu is open.
2025-05-23 16:12:18 +05:30
22 changed files with 148 additions and 178 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,11 +38,6 @@ 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,8 +102,4 @@ 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,9 +51,6 @@ 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,11 +315,7 @@ const componentToRender = computed(() => {
}); });
const shouldShowContextMenu = computed(() => { const shouldShowContextMenu = computed(() => {
return !( return !props.contentAttributes?.isUnsupported;
props.status === MESSAGE_STATUS.FAILED ||
props.status === MESSAGE_STATUS.PROGRESS ||
props.contentAttributes?.isUnsupported
);
}); });
const isBubble = computed(() => { const isBubble = computed(() => {
@@ -344,12 +340,23 @@ 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: hasText || hasAttachments, delete:
cannedResponse: isOutgoing && hasText, (hasText || hasAttachments) &&
replyTo: !props.private && props.inboxSupportsReplyTo.outgoing, !isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
}; };
}); });
@@ -499,8 +506,8 @@ provideMessageContext({
<div <div
class="[grid-area:bubble] flex" class="[grid-area:bubble] flex"
:class="{ :class="{
'ltr:pl-8 rtl:pr-8 justify-end': orientation === ORIENTATION.RIGHT, 'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
'ltr:pr-8 rtl:pl-8': orientation === ORIENTATION.LEFT, 'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL, 'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
}" }"
@contextmenu="openContextMenu($event)" @contextmenu="openContextMenu($event)"
@@ -516,7 +523,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 && !isMessageDeleted" v-if="isBubble"
:context-menu-position="contextMenuPosition" :context-menu-position="contextMenuPosition"
:is-open="showContextMenu" :is-open="showContextMenu"
:enabled-options="contextMenuEnabledOptions" :enabled-options="contextMenuEnabledOptions"
@@ -8,7 +8,6 @@ import {
computed, computed,
watch, watch,
onMounted, onMounted,
onUnmounted,
defineEmits, defineEmits,
} from 'vue'; } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
@@ -44,7 +43,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, useScrollLock } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -87,12 +86,8 @@ const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null); const conversationDynamicScroller = ref(null);
const conversationListScrollableElement = computed(
() => conversationDynamicScroller.value?.$el provide('contextMenuElementTarget', conversationDynamicScroller);
);
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);
@@ -746,7 +741,6 @@ function allSelectedConversationsStatus(status) {
function onContextMenuToggle(state) { function onContextMenuToggle(state) {
isContextMenuOpen.value = state; isContextMenuOpen.value = state;
conversationListScrollLock.value = state;
} }
function toggleSelectAll(check) { function toggleSelectAll(check) {
@@ -770,10 +764,6 @@ onMounted(() => {
} }
}); });
onUnmounted(() => {
conversationListScrollLock.value = false;
});
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@@ -1,6 +1,13 @@
<script setup> <script setup>
import { computed, onMounted, nextTick, useTemplateRef } from 'vue'; import {
import { useWindowSize, useElementBounding } from '@vueuse/core'; computed,
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';
@@ -11,27 +18,34 @@ 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; const isOverflowingRight = left + menuW > windowW - PADDING;
const isOverflowingBottom = top + menuH > windowH; const isOverflowingBottom = top + menuH > windowH - PADDING;
// Adjust position if overflowing // Adjust position if overflowing
if (isOverflowingRight) left = windowW - menuW; if (isOverflowingRight) left = windowW - menuW - PADDING;
if (isOverflowingBottom) top = windowH - menuH; if (isOverflowingBottom) top = windowH - menuH - PADDING;
return { return {
left: Math.max(0, left), left: Math.max(PADDING, left),
top: Math.max(0, top), top: Math.max(PADDING, top),
}; };
}; };
@@ -54,8 +68,18 @@ 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>
@@ -65,7 +89,7 @@ onMounted(() => {
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="emit('close')" @blur="handleClose"
> >
<slot /> <slot />
</div> </div>
@@ -185,8 +185,17 @@ export default {
contextMenuEnabledOptions() { contextMenuEnabledOptions() {
return { return {
copy: this.hasText, copy: this.hasText,
delete: this.hasText || this.hasAttachments, delete:
cannedResponse: this.isOutgoing && this.hasText, (this.hasText || this.hasAttachments) &&
!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,
}; };
}, },
@@ -328,7 +337,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.isFailed || this.isPending || this.isUnsupported); return !this.isUnsupported;
}, },
showAvatar() { showAvatar() {
if (this.isOutgoing || this.isTemplate) { if (this.isOutgoing || this.isTemplate) {
@@ -1,5 +1,5 @@
<script> <script>
import { ref } from 'vue'; import { ref, provide } 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,6 +63,7 @@ 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 = () => {
@@ -98,6 +99,8 @@ export default {
FEATURE_FLAGS.CHATWOOT_V4 FEATURE_FLAGS.CHATWOOT_V4
); );
provide('contextMenuElementTarget', conversationPanelRef);
return { return {
isEnterprise, isEnterprise,
isPopOutReplyBox, isPopOutReplyBox,
@@ -108,6 +111,7 @@ export default {
fetchIntegrationsIfRequired, fetchIntegrationsIfRequired,
fetchLabelSuggestions, fetchLabelSuggestions,
showNextBubbles, showNextBubbles,
conversationPanelRef,
}; };
}, },
data() { data() {
@@ -541,6 +545,7 @@ 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"
@@ -572,7 +577,7 @@ export default {
/> />
</template> </template>
</NextMessageList> </NextMessageList>
<ul v-else class="conversation-panel"> <ul v-else ref="conversationPanelRef" 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,10 +76,7 @@
"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,6 +47,7 @@ export default {
emits: ['open', 'close', 'replyTo'], emits: ['open', 'close', 'replyTo'],
setup() { setup() {
const { getPlainText } = useMessageFormatter(); const { getPlainText } = useMessageFormatter();
return { return {
getPlainText, getPlainText,
}; };
@@ -167,7 +168,7 @@ export default {
</woot-modal> </woot-modal>
<!-- Confirm Deletion --> <!-- Confirm Deletion -->
<woot-delete-modal <woot-delete-modal
v-if="showDeleteModal" v-if="showDeleteModal && enabledOptions['delete']"
v-model:show="showDeleteModal" v-model:show="showDeleteModal"
class="context-menu--delete-modal" class="context-menu--delete-modal"
:on-close="closeDeleteModal" :on-close="closeDeleteModal"
@@ -212,7 +213,7 @@ export default {
@click.stop="handleCopy" @click.stop="handleCopy"
/> />
<MenuItem <MenuItem
v-if="enabledOptions['copy']" v-if="enabledOptions['translate']"
:option="{ :option="{
icon: 'translate', icon: 'translate',
label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'), label: $t('CONVERSATION.CONTEXT_MENU.TRANSLATE'),
@@ -222,6 +223,7 @@ 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', 'onReset']); const emit = defineEmits(['onCopy']);
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,10 +20,6 @@ const maskIcon = computed(() => {
const onClick = () => { const onClick = () => {
emit('onCopy', props.value); emit('onCopy', props.value);
}; };
const onReset = () => {
emit('onReset');
};
</script> </script>
<template> <template>
@@ -60,15 +56,5 @@ const onReset = () => {
> >
{{ $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,14 +181,6 @@ 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>
@@ -289,11 +281,7 @@ export default {
) )
" "
> >
<AccessToken <AccessToken :value="currentUser.access_token" @on-copy="onCopyToken" />
:value="currentUser.access_token"
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection> </FormSection>
</div> </div>
</template> </template>
@@ -213,16 +213,6 @@ 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();
@@ -1 +0,0 @@
json.partial! 'api/v1/models/user', formats: [:json], resource: @user
+3
View File
@@ -16,13 +16,16 @@ 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,7 +295,6 @@ 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], title: copilot_thread_params[:message][:content],
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].blank? return render_could_not_create_error('Message is required') if copilot_thread_params[:message][:content].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(:message, :assistant_id) params.permit(:assistant_id, message: [:content])
end end
def permitted_params def permitted_params
@@ -296,32 +296,4 @@ 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: 'Hello, how can you help me?', assistant_id: assistant.id } } let(:valid_params) { { message: { content: '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: '', assistant_id: assistant.id }, params: { message: { content: '' }, 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: 'Hello', assistant_id: 0 }, params: { message: { content: '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]) expect(thread.title).to eq(valid_params[:message][:content])
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]) expect(message.message).to eq(valid_params[:message].stringify_keys)
end end
end end
end end
@@ -2,8 +2,9 @@ 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) } let(:service) { described_class.new(assistant, user: user) }
describe '#name' do describe '#name' do
it 'returns the correct service name' do it 'returns the correct service name' do
@@ -7,6 +7,10 @@ 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) }
@@ -184,7 +188,11 @@ 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, %r{https://graph\.instagram\.com/v22\.0/Sender-id-1\?.*}) stub_request(:get, '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: {
@@ -201,6 +209,7 @@ 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
@@ -268,7 +277,8 @@ 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, %r{https://graph\.instagram\.com/v22\.0/.*\?.*}) stub_request(:get, 'https://graph.instagram.com/v22.0/Sender-id-1')
.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])