Files
leadchat/spec/controllers/api/v1/accounts/conversations_controller_spec.rb
Shivam Mishra 9a9398b386 feat: validate OpenAPI spec using Skooma (#13623)
Adds Skooma-based OpenAPI validation so SDK-facing request specs can
assert that documented request and response contracts match real Rails
behavior. This also upgrades the spec to OpenAPI 3.1 and fixes contract
drift uncovered while validating core application and platform
resources.

Closes
None

Why
We want CI to catch OpenAPI drift before it reaches SDK consumers. While
wiring validation in, this PR surfaced several mismatches between the
documented contract and what the Rails endpoints actually accept or
return.

What this change does
- Adds Skooma-backed OpenAPI validation to the request spec flow and a
dedicated OpenAPI validation spec.
- Migrates nullable schema definitions to OpenAPI 3.1-compatible unions.
- Updates core SDK-facing schemas and payloads across accounts,
contacts, conversations, inboxes, messages, teams, reporting events, and
platform account resources.
- Documents concrete runtime cases that were previously missing or
inaccurate, including nested `profile` update payloads, multipart avatar
uploads, required profile update bodies, nullable inbox feature flags,
and message sender types that include both `Captain::Assistant` and
senderless activity-style messages.
- Regenerates the committed Swagger JSON and tag-group artifacts used by
CI sync checks.

Validation
- `bundle exec rake swagger:build`
- `bundle exec rspec spec/swagger/openapi_spec.rb`

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-10 18:33:55 -07:00

1100 lines
45 KiB
Ruby

require 'rails_helper'
RSpec.describe 'Conversations API', type: :request do
let(:account) { create(:account) }
describe 'GET /api/v1/accounts/{account.id}/conversations' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations"
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) }
let(:conversation) { create(:conversation, account: account) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns all conversations with messages' do
message = create(:message, conversation: conversation, account: account)
get "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:data][:meta][:all_count]).to eq(1)
expect(body[:data][:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
expect(body[:data][:payload].first[:uuid]).to eq(conversation.uuid)
expect(body[:data][:payload].first[:messages].first[:id]).to eq(message.id)
end
it 'returns conversations with empty messages array for conversations with out messages' do
get "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:data][:meta][:all_count]).to eq(1)
expect(body[:data][:payload].first[:messages]).to eq([])
end
it 'returns unattended conversations' do
attended_conversation = create(:conversation, account: account, first_reply_created_at: Time.now.utc)
# to ensure that waiting since value is populated
create(:message, message_type: :outgoing, conversation: attended_conversation, account: account)
unattended_conversation_no_first_reply = create(:conversation, account: account, first_reply_created_at: nil)
unattended_conversation_waiting_since = create(:conversation, account: account, first_reply_created_at: Time.now.utc)
agent_1 = create(:user, account: account, role: :agent)
create(:inbox_member, user: agent_1, inbox: attended_conversation.inbox)
create(:inbox_member, user: agent_1, inbox: unattended_conversation_no_first_reply.inbox)
create(:inbox_member, user: agent_1, inbox: unattended_conversation_waiting_since.inbox)
get "/api/v1/accounts/#{account.id}/conversations",
headers: agent_1.create_new_auth_token,
params: { conversation_type: 'unattended' },
as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:data][:meta][:all_count]).to eq(2)
expect(body[:data][:payload].count).to eq(2)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/meta' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/meta"
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) }
before do
conversation = create(:conversation, account: account)
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns all conversations counts' do
get "/api/v1/accounts/#{account.id}/conversations/meta",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:meta].keys).to include(:all_count, :mine_count, :assigned_count, :unassigned_count)
expect(body[:meta][:all_count]).to eq(1)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/search' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/search", params: { q: 'test' }
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) }
before do
conversation = create(:conversation, account: account)
create(:message, conversation: conversation, account: account, content: 'test1')
create(:message, conversation: conversation, account: account, content: 'test2')
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns all conversations with messages containing the search query' do
get "/api/v1/accounts/#{account.id}/conversations/search",
headers: agent.create_new_auth_token,
params: { q: 'test1' },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:meta][:all_count]).to eq(1)
expect(response_data[:payload].first[:messages].first[:content]).to eq 'test1'
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/filter' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/filter", params: { q: 'test' }
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) }
before do
conversation = create(:conversation, account: account)
create(:message, conversation: conversation, account: account, content: 'test1')
create(:message, conversation: conversation, account: account, content: 'test2')
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns all conversations matching the query' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data.count).to eq(2)
end
it 'returns error if the filters contain invalid attributes' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'phone_number',
filter_operator: 'equal_to',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to include('Invalid attribute key - [phone_number]')
end
it 'returns error if the filters contain invalid operator' do
post "/api/v1/accounts/#{account.id}/conversations/filter",
headers: agent.create_new_auth_token,
params: {
payload: [{
attribute_key: 'status',
filter_operator: 'eq',
values: ['open']
}]
},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:error]).to eq('Invalid operator. The allowed operators for status are [equal_to,not_equal_to].')
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}"
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) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'does not shows the conversation if you do not have access to it' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'shows the conversation if you are an administrator' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
end
it 'shows the conversation if you are an agent with access to inbox' do
create(:inbox_member, user: agent, inbox: conversation.inbox)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(conversation.display_id)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
let(:params) { { priority: 'high' } }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params
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) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'does not update the conversation if you do not have access to it' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates the conversation if you are an administrator' do
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
it 'updates the conversation if you are an agent with access to inbox' do
create(:inbox_member, user: agent, inbox: conversation.inbox)
patch "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(JSON.parse(response.body, symbolize_names: true)[:priority]).to eq('high')
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations' do
let(:contact) { create(:contact, account: account) }
let(:inbox) { create(:inbox, account: account) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations",
params: { source_id: contact_inbox.source_id },
as: :json
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, auto_offline: false) }
let(:team) { create(:team, account: account) }
it 'will not create a new conversation if agent does not have access to inbox' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
additional_attributes = { test: 'test' }
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, additional_attributes: additional_attributes },
as: :json
expect(response).to have_http_status(:unauthorized)
end
context 'when it is an authenticated user who has access to the inbox' do
before do
create(:inbox_member, user: agent, inbox: inbox)
create(:team_member, user: agent, team: team)
end
it 'creates a new conversation' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
additional_attributes = { test: 'test' }
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, additional_attributes: additional_attributes },
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:additional_attributes]).to eq(additional_attributes)
end
it 'does not create a new conversation if source_id is not unique' do
new_contact = create(:contact, account: account)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, inbox_id: inbox.id, contact_id: new_contact.id },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'creates a conversation in specificed status' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, status: 'pending' },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:status]).to eq('pending')
end
it 'creates a new conversation with message when message is passed' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, message: { content: 'hi' } },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:additional_attributes]).to eq({})
expect(account.conversations.find_by(display_id: response_data[:id]).messages.outgoing.first.content).to eq 'hi'
end
it 'calls contact inbox builder if contact_id and inbox_id is present' do
builder = double
allow(Rails.configuration.dispatcher).to receive(:dispatch)
allow(ContactInboxBuilder).to receive(:new).with(contact: contact, inbox: inbox, source_id: nil, hmac_verified: false).and_return(builder)
allow(builder).to receive(:perform)
expect(builder).to receive(:perform)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { contact_id: contact.id, inbox_id: inbox.id, hmac_verified: 'false' },
as: :json
end
it 'creates a new conversation with assignee and team' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations",
headers: agent.create_new_auth_token,
params: { source_id: contact_inbox.source_id, contact_id: contact.id, inbox_id: inbox.id, assignee_id: agent.id, team_id: team.id },
as: :json
expect(response).to have_http_status(:success)
response_data = JSON.parse(response.body, symbolize_names: true)
expect(response_data[:meta][:assignee][:name]).to eq(agent.name)
expect(response_data[:meta][:team][:name]).to eq(team.name)
end
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_status' do
let(:conversation) { create(:conversation, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:pending_conversation) { create(:conversation, inbox: inbox, account: account, status: 'pending') }
let(:agent_bot) { create(:agent_bot, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status"
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) }
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'toggles the conversation status if status is empty' do
expect(conversation.status).to eq('open')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: '' },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('resolved')
end
it 'toggles the conversation status to open from pending' do
conversation.update!(status: 'pending')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(response).to conform_schema(200)
expect(conversation.reload.status).to eq('open')
end
it 'self assign if agent changes the conversation status to open' do
conversation.update!(status: 'pending')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('open')
expect(conversation.reload.assignee_id).to eq(agent.id)
end
it 'disbale self assign if admin changes the conversation status to open' do
conversation.update!(status: 'pending')
conversation.update!(assignee_id: nil)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('open')
expect(conversation.reload.assignee_id).not_to eq(administrator.id)
end
it 'toggles the conversation status to specific status when parameter is passed' do
expect(conversation.status).to eq('open')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'pending' },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('pending')
end
it 'toggles the conversation status to snoozed when parameter is passed' do
expect(conversation.status).to eq('open')
snoozed_until = (DateTime.now.utc + 2.days).to_i
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: agent.create_new_auth_token,
params: { status: 'snoozed', snoozed_until: snoozed_until },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('snoozed')
expect(conversation.reload.snoozed_until.to_i).to eq(snoozed_until)
end
end
context 'when it is an authenticated bot' do
# this test will basically ensure that the status actually changes
# regardless of the value to be done
it 'returns authorized for arbritrary status' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update!(status: 'open')
expect(conversation.reload.status).to eq('open')
snoozed_until = (DateTime.now.utc + 2.days).to_i
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'snoozed', snoozed_until: snoozed_until },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.status).to eq('snoozed')
end
it 'triggers handoff event when moving from pending to open' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{pending_conversation.display_id}/toggle_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { status: 'open' },
as: :json
expect(response).to have_http_status(:success)
expect(pending_conversation.reload.status).to eq('open')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Events::Types::CONVERSATION_BOT_HANDOFF, kind_of(Time), conversation: pending_conversation, notifiable_assignee_change: false,
changed_attributes: anything, performed_by: anything)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_priority' do
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:pending_conversation) { create(:conversation, inbox: inbox, account: account, status: 'pending') }
let(:agent) { create(:user, account: account, role: :agent) }
let(:agent_bot) { create(:agent_bot, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_priority"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'toggles the conversation priority to nil if no value is passed' do
expect(conversation.priority).to be_nil
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_priority",
headers: agent.create_new_auth_token,
params: { priority: 'low' },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.priority).to eq('low')
end
it 'toggles the conversation priority' do
conversation.priority = 'low'
conversation.save!
expect(conversation.reload.priority).to eq('low')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_priority",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.priority).to be_nil
end
end
context 'when it is an authenticated bot' do
it 'toggle the priority of the bot agent conversation' do
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
conversation.update!(priority: 'low')
expect(conversation.reload.priority).to eq('low')
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_priority",
headers: { api_access_token: agent_bot.access_token.token },
params: { priority: 'high' },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.priority).to eq('high')
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/toggle_typing_status' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status"
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) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'toggles the conversation status' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: agent.create_new_auth_token,
params: { typing_status: 'on', is_private: false },
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: agent, is_private: false })
end
it 'toggles the conversation status for private notes' do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: agent.create_new_auth_token,
params: { typing_status: 'on', is_private: true },
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: agent, is_private: true })
end
end
context 'when it is an authenticated bot' do
let(:agent_bot) { create(:agent_bot, account: account) }
it 'toggles the conversation typing status' do
create(:agent_bot_inbox, inbox: conversation.inbox, agent_bot: agent_bot)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: { api_access_token: agent_bot.access_token.token },
params: { typing_status: 'on', is_private: false },
as: :json
expect(response).to have_http_status(:success)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Conversation::CONVERSATION_TYPING_ON, kind_of(Time), { conversation: conversation, user: agent_bot, is_private: false })
end
end
context 'when it is an authenticated platform app token' do
let(:platform_app) { create(:platform_app) }
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/toggle_typing_status",
headers: { api_access_token: platform_app.access_token.token },
params: { typing_status: 'on', is_private: false },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/update_last_seen' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen"
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) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'updates last seen' do
conversation.update!(agent_last_seen_at: nil)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.agent_last_seen_at).not_to be_nil
end
it 'updates assignee last seen' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: nil)
expect(conversation.reload.assignee_last_seen_at).to be_nil
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.assignee_last_seen_at).not_to be_nil
end
it 'throttles updates within an hour when there are no unread messages' do
conversation.update!(agent_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than agent_last_seen_at (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_last_seen)
end
it 'updates even within an hour when there are unread messages' do
conversation.update!(agent_last_seen_at: 30.minutes.ago)
# Create a new message after agent_last_seen_at (unread message)
create(:message, conversation: conversation, created_at: 5.minutes.ago)
initial_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.agent_last_seen_at).not_to be_within(1.second).of(initial_last_seen)
expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen
end
it 'updates both if one timestamp is old even when the other is recent' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_agent_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
# Both should be updated because agent_last_seen_at is old
expect(conversation.reload.agent_last_seen_at).to be > initial_agent_last_seen
expect(conversation.reload.assignee_last_seen_at).to be > initial_agent_last_seen
end
it 'throttles only when both timestamps are recent and no unread messages' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 30.minutes.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_agent_last_seen = conversation.agent_last_seen_at
initial_assignee_last_seen = conversation.assignee_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
# Both should remain unchanged (throttled)
expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_agent_last_seen)
expect(conversation.reload.assignee_last_seen_at).to be_within(1.second).of(initial_assignee_last_seen)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/unread' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread"
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) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
create(:message, conversation: conversation, account: account, inbox: conversation.inbox, content: 'Hello', message_type: 'incoming')
end
it 'updates last seen' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unread",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
last_seen_at = conversation.messages.incoming.last.created_at - 1.second
expect(conversation.reload.agent_last_seen_at).to eq(last_seen_at)
expect(conversation.reload.assignee_last_seen_at).to eq(last_seen_at)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/mute' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/mute"
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) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'mutes conversation' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/mute",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.resolved?).to be(true)
expect(conversation.reload.muted?).to be(true)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/unmute' do
let(:conversation) { create(:conversation, account: account).tap(&:mute!) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unmute"
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) }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'unmutes conversation' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/unmute",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.muted?).to be(false)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/transcript' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/transcript"
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) }
let(:params) { { email: 'test@test.com' } }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'mutes conversation' do
mailer = double
allow(ConversationReplyMailer).to receive(:with).and_return(mailer)
allow(mailer).to receive(:conversation_transcript)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/transcript",
headers: agent.create_new_auth_token,
params: params,
as: :json
expect(response).to have_http_status(:success)
expect(mailer).to have_received(:conversation_transcript).with(conversation, 'test@test.com')
end
it 'renders error when parameter missing' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/transcript",
headers: agent.create_new_auth_token,
params: {},
as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/conversations/:id/custom_attributes' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes"
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) }
let(:custom_attributes) { { user_id: 1001, created_date: '23/12/2012', subscription_id: 12 } }
let(:valid_params) { { custom_attributes: custom_attributes } }
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'updates custom attributes' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes",
headers: agent.create_new_auth_token,
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.custom_attributes).not_to be_nil
expect(conversation.reload.custom_attributes.count).to eq 3
end
end
context 'when it is a bot' do
let(:agent_bot) { create(:agent_bot, account: account) }
let(:custom_attributes) { { bot_id: 1001, flow_name: 'support_flow', step: 'greeting' } }
let(:valid_params) { { custom_attributes: custom_attributes } }
before do
create(:agent_bot_inbox, agent_bot: agent_bot, inbox: conversation.inbox)
end
it 'updates custom attributes' do
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/custom_attributes",
headers: { api_access_token: agent_bot.access_token.token },
params: valid_params,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.custom_attributes).not_to be_nil
expect(conversation.reload.custom_attributes.count).to eq 3
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/attachments' do
let(:conversation) { create(:conversation, account: account) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments"
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) }
let(:administrator) { create(:user, account: account, role: :administrator) }
before do
create(:message, :with_attachment, conversation: conversation, account: account, inbox: conversation.inbox, message_type: 'incoming')
end
it 'does not return the attachments if you do not have access to it' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'return the attachments if you are an administrator' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].first['file_type']).to eq('image')
expect(response_body['payload'].first['sender']['id']).to eq(conversation.messages.last.sender.id)
end
it 'return the attachments if you are an agent with access to inbox' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/attachments",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_body = response.parsed_body
expect(response_body['payload'].length).to eq(1)
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/conversations/:id' do
let(:conversation) { create(:conversation, account: account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated agent' do
before do
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
response_body = response.parsed_body
expect(response_body['error']).to eq('You are not authorized to do this action')
end
end
context 'when it is an authenticated administrator' do
before do
create(:inbox_member, user: administrator, inbox: conversation.inbox)
end
it 'successfully deletes the conversation' do
expect do
delete "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: administrator.create_new_auth_token,
as: :json
end.to have_enqueued_job(DeleteObjectJob).with(conversation, administrator, anything)
expect(response).to have_http_status(:ok)
end
it 'can delete conversations from inboxes without direct access' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
expect do
delete "/api/v1/accounts/#{account.id}/conversations/#{other_conversation.display_id}",
headers: administrator.create_new_auth_token,
as: :json
end.to have_enqueued_job(DeleteObjectJob).with(other_conversation, administrator, anything)
expect(response).to have_http_status(:ok)
end
end
end
end