Merge branch 'develop' into feat/CW-6641
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -14,15 +22,23 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
bulk_create_agents(emails)
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
clear_onboarding_step
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,7 +53,11 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<BaseTableCell class="max-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:name="bot.name || ''"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
class="flex-shrink-0"
|
||||
|
||||
@@ -68,6 +68,10 @@ const isAgentBot = computed(
|
||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const selectedItemName = computed(() =>
|
||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
||||
);
|
||||
|
||||
const selectedThumbnail = computed(
|
||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||
);
|
||||
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
|
||||
<h4
|
||||
v-else
|
||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
:title="selectedItem.name"
|
||||
:title="selectedItemName"
|
||||
>
|
||||
{{ selectedItem.name }}
|
||||
{{ selectedItemName }}
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||
:src="selectedThumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItem.name"
|
||||
:name="selectedItemName"
|
||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||
:size="24"
|
||||
hide-offline-status
|
||||
|
||||
@@ -53,7 +53,9 @@ export default {
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
return this.options.filter(option => {
|
||||
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||
return (option.name || '')
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
noResult() {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -57,6 +62,9 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -90,19 +98,35 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -144,6 +168,7 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -6,12 +6,10 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||
if should_send_template_message
|
||||
send_template_message
|
||||
else
|
||||
send_session_message
|
||||
end
|
||||
return send_template_message if template_params.present?
|
||||
return send_session_message if message.conversation.can_reply?
|
||||
|
||||
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
|
||||
@@ -154,6 +154,7 @@ en:
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
||||
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -67,5 +73,17 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,9 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
it 'deletes the linear integration' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -21,11 +23,19 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
@@ -159,15 +159,17 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/shopify' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
create(:integrations_hook, :shopify, account: account)
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'when it is an administrator' do
|
||||
it 'deletes the shopify integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change { account.hooks.count }.by(-1)
|
||||
|
||||
@@ -175,6 +177,18 @@ RSpec.describe 'Shopify Integration API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized and keeps the integration' do
|
||||
expect do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to(change { account.hooks.count })
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/shopify",
|
||||
|
||||
@@ -21,6 +21,27 @@ RSpec.describe 'Agents API', type: :request do
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
end
|
||||
|
||||
it 'prevents adding an agent if the last seat is consumed before creation' do
|
||||
account.update!(limits: { agents: account.account_users.count + 1 })
|
||||
competing_agent_created = false
|
||||
|
||||
allow(AgentBuilder).to receive(:new).and_wrap_original do |method, *args|
|
||||
unless competing_agent_created
|
||||
create(:user, account: account, role: :agent)
|
||||
competing_agent_created = true
|
||||
end
|
||||
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agents", params: params, headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:payment_required)
|
||||
expect(response.body).to include('Account limit exceeded. Please purchase more licenses')
|
||||
expect(User.from_email(params[:email])).to be_nil
|
||||
expect(account.account_users.count).to eq(account.usage_limits[:agents])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -72,6 +72,21 @@ describe Whatsapp::SendOnWhatsappService do
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
|
||||
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
|
||||
conversation: conversation, account: conversation.account)
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
|
||||
end
|
||||
|
||||
it 'marks message as failed when template name is blank' do
|
||||
processor = instance_double(Whatsapp::TemplateProcessorService)
|
||||
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
||||
|
||||
Reference in New Issue
Block a user