feat: Backend - Companies API endpoint with pagination and search (#12840)
## Description Adds API endpoint to list companies with pagination, search, and sorting. Fixes https://linear.app/chatwoot/issue/CW-5930/add-backend-routes-to-get-companies-result Parent issue: https://linear.app/chatwoot/issue/CW-5928/add-companies-tab-to-dashboard ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Added comprehensive specs to `spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb`: - Pagination (25 per page, multiple pages) - Search by name and domain (case-insensitive) - Counter cache for contacts_count - Account scoping - Authorization To reproduce: ```bash bundle exec rspec spec/enterprise/controllers/api/v1/accounts/companies_controller_spec.rb bundle exec rubocop enterprise/app/controllers/api/v1/accounts/companies_controller.rb ``` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
13
app/jobs/migration/backfill_companies_contacts_count_job.rb
Normal file
13
app/jobs/migration/backfill_companies_contacts_count_job.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
class Migration::BackfillCompaniesContactsCountJob < ApplicationJob
|
||||||
|
queue_as :async_database_migration
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return unless ChatwootApp.enterprise?
|
||||||
|
|
||||||
|
Company.find_in_batches(batch_size: 100) do |company_batch|
|
||||||
|
company_batch.each do |company|
|
||||||
|
Company.reset_counters(company.id, :contacts)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -80,6 +80,8 @@ en:
|
|||||||
companies:
|
companies:
|
||||||
domain:
|
domain:
|
||||||
invalid: must be a valid domain name
|
invalid: must be a valid domain name
|
||||||
|
search:
|
||||||
|
query_missing: Specify search string with parameter q
|
||||||
categories:
|
categories:
|
||||||
locale:
|
locale:
|
||||||
unique: should be unique in the category and portal
|
unique: should be unique in the category and portal
|
||||||
|
|||||||
@@ -154,7 +154,11 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :companies, only: [:index, :show, :create, :update, :destroy]
|
resources :companies, only: [:index, :show, :create, :update, :destroy] do
|
||||||
|
collection do
|
||||||
|
get :search
|
||||||
|
end
|
||||||
|
end
|
||||||
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
resources :contacts, only: [:index, :show, :update, :create, :destroy] do
|
||||||
collection do
|
collection do
|
||||||
get :active
|
get :active
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddContactsCountToCompanies < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :companies, :contacts_count, :integer, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -577,6 +577,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_11_14_173609) do
|
|||||||
t.bigint "account_id", null: false
|
t.bigint "account_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "contacts_count"
|
||||||
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
||||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
|
class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||||
|
include Sift
|
||||||
|
sort_on :name, type: :string
|
||||||
|
sort_on :domain, type: :string
|
||||||
|
sort_on :created_at, type: :datetime
|
||||||
|
|
||||||
|
RESULTS_PER_PAGE = 25
|
||||||
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
before_action :set_current_page, only: [:index, :search]
|
||||||
before_action :fetch_company, only: [:show, :update, :destroy]
|
before_action :fetch_company, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@companies = Current.account.companies.ordered_by_name
|
@companies = fetch_companies(resolved_companies)
|
||||||
|
@companies_count = @companies.total_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def search
|
||||||
|
if params[:q].blank?
|
||||||
|
return render json: { error: I18n.t('errors.companies.search.query_missing') },
|
||||||
|
status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
companies = resolved_companies.search_by_name_or_domain(params[:q])
|
||||||
|
@companies = fetch_companies(companies)
|
||||||
|
@companies_count = @companies.total_count
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -24,6 +44,20 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::EnterpriseAcco
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def resolved_companies
|
||||||
|
@resolved_companies ||= Current.account.companies
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_current_page
|
||||||
|
@current_page = params[:page] || 1
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_companies(companies)
|
||||||
|
filtrate(companies)
|
||||||
|
.page(@current_page)
|
||||||
|
.per(RESULTS_PER_PAGE)
|
||||||
|
end
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
|
raise Pundit::NotAuthorizedError unless ChatwootApp.enterprise?
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# Table name: companies
|
# Table name: companies
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
|
# contacts_count :integer
|
||||||
# description :text
|
# description :text
|
||||||
# domain :string
|
# domain :string
|
||||||
# name :string not null
|
# name :string not null
|
||||||
@@ -31,4 +32,7 @@ class Company < ApplicationRecord
|
|||||||
has_many :contacts, dependent: :nullify
|
has_many :contacts, dependent: :nullify
|
||||||
|
|
||||||
scope :ordered_by_name, -> { order(:name) }
|
scope :ordered_by_name, -> { order(:name) }
|
||||||
|
scope :search_by_name_or_domain, lambda { |query|
|
||||||
|
where('name ILIKE :search OR domain ILIKE :search', search: "%#{query.strip}%")
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
module Enterprise::Concerns::Contact
|
module Enterprise::Concerns::Contact
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
included do
|
included do
|
||||||
belongs_to :company, optional: true
|
belongs_to :company, optional: true, counter_cache: true
|
||||||
|
|
||||||
after_commit :associate_company_from_email,
|
after_commit :associate_company_from_email,
|
||||||
on: [:create, :update],
|
on: [:create, :update],
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ class CompanyPolicy < ApplicationPolicy
|
|||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def search?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
def show?
|
def show?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ class Contacts::CompanyAssociationService
|
|||||||
return nil if skip_association?(contact)
|
return nil if skip_association?(contact)
|
||||||
|
|
||||||
company = find_or_create_company(contact)
|
company = find_or_create_company(contact)
|
||||||
|
if company
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
# Intentionally using update_column here to:
|
# Using update_column and increment_counter to avoid triggering callbacks while maintaining counter cache
|
||||||
# 1. Avoid triggering callbacks
|
contact.update_column(:company_id, company.id)
|
||||||
# 2. Improve performance (We're only setting company_id, no need for validation)
|
Company.increment_counter(:contacts_count, company.id)
|
||||||
contact.update_column(:company_id, company.id) if company
|
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
end
|
||||||
company
|
company
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
json.id company.id
|
json.id company.id
|
||||||
json.name company.name
|
json.name company.name
|
||||||
|
json.contacts_count company.contacts_count
|
||||||
json.domain company.domain
|
json.domain company.domain
|
||||||
json.description company.description
|
json.description company.description
|
||||||
json.avatar_url company.avatar_url
|
json.avatar_url company.avatar_url
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
json.meta do
|
||||||
|
json.total_count @companies_count
|
||||||
|
json.page @current_page
|
||||||
|
end
|
||||||
|
|
||||||
json.payload do
|
json.payload do
|
||||||
json.array! @companies do |company|
|
json.array! @companies do |company|
|
||||||
json.partial! 'company', company: company
|
json.partial! 'company', company: company
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
json.meta do
|
||||||
|
json.total_count @companies_count
|
||||||
|
json.page @current_page
|
||||||
|
end
|
||||||
|
|
||||||
|
json.payload do
|
||||||
|
json.array! @companies do |company|
|
||||||
|
json.partial! 'company', company: company
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -25,10 +25,150 @@ RSpec.describe 'Companies API', type: :request do
|
|||||||
expect(response_body['payload'].size).to eq(2)
|
expect(response_body['payload'].size).to eq(2)
|
||||||
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
|
expect(response_body['payload'].map { |c| c['name'] }).to contain_exactly(company1.name, company2.name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns companies with pagination' do
|
||||||
|
create_list(:company, 30, account: account)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies",
|
||||||
|
params: { page: 1 },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(25)
|
||||||
|
expect(response_body['meta']['total_count']).to eq(32)
|
||||||
|
expect(response_body['meta']['page']).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns second page of companies' do
|
||||||
|
create_list(:company, 30, account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies",
|
||||||
|
params: { page: 2 },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(7)
|
||||||
|
expect(response_body['meta']['total_count']).to eq(32)
|
||||||
|
expect(response_body['meta']['page']).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns companies with contacts_count' do
|
||||||
|
company_with_contacts = create(:company, name: 'Company With Contacts', account: account)
|
||||||
|
create_list(:contact, 5, company: company_with_contacts, account: account)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
company_data = response_body['payload'].find { |c| c['id'] == company_with_contacts.id }
|
||||||
|
expect(company_data['contacts_count']).to eq(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not return companies from other accounts' do
|
||||||
|
other_account = create(:account)
|
||||||
|
create(:company, name: 'Other Account Company', account: other_account)
|
||||||
|
create(:company, name: 'My Company', account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(3)
|
||||||
|
expect(response_body['payload'].map { |c| c['name'] }).not_to include('Other Account Company')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/{account.id}/companies/search' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
|
|
||||||
|
it 'returns error when q parameter is missing' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Specify search string with parameter q')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'searches companies by name' do
|
||||||
|
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||||
|
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||||
|
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||||
|
params: { q: 'tech' },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(1)
|
||||||
|
expect(response_body['payload'].first['name']).to eq('Tech Solutions')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'searches companies by domain' do
|
||||||
|
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||||
|
create(:company, name: 'Tech Solutions', domain: 'tech.com', account: account)
|
||||||
|
create(:company, name: 'Global Inc', domain: 'global.com', account: account)
|
||||||
|
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||||
|
params: { q: 'acme.com' },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(1)
|
||||||
|
expect(response_body['payload'].first['domain']).to eq('acme.com')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'search is case insensitive' do
|
||||||
|
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||||
|
params: { q: 'ACME' },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
|
||||||
|
expect(response_body['payload'].size).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array when no companies match search' do
|
||||||
|
create(:company, name: 'Acme Corp', domain: 'acme.com', account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/search",
|
||||||
|
params: { q: 'nonexistent' },
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
response_body = response.parsed_body
|
||||||
|
expect(response_body['payload'].size).to eq(0)
|
||||||
|
expect(response_body['meta']['total_count']).to eq(0)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
|
describe 'GET /api/v1/accounts/{account.id}/companies/{id}' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
company = create(:company, account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when it is an authenticated user' do
|
context 'when it is an authenticated user' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:company) { create(:company, account: account) }
|
let(:company) { create(:company, account: account) }
|
||||||
@@ -46,6 +186,13 @@ RSpec.describe 'Companies API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST /api/v1/accounts/{account.id}/companies' do
|
describe 'POST /api/v1/accounts/{account.id}/companies' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/companies"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when it is an authenticated user' do
|
context 'when it is an authenticated user' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:valid_params) do
|
let(:valid_params) do
|
||||||
@@ -85,6 +232,14 @@ RSpec.describe 'Companies API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
|
describe 'PATCH /api/v1/accounts/{account.id}/companies/{id}' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
company = create(:company, account: account)
|
||||||
|
patch "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when it is an authenticated user' do
|
context 'when it is an authenticated user' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:company) { create(:company, account: account) }
|
let(:company) { create(:company, account: account) }
|
||||||
@@ -111,6 +266,14 @@ RSpec.describe 'Companies API', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
|
describe 'DELETE /api/v1/accounts/{account.id}/companies/{id}' do
|
||||||
|
context 'when it is an unauthenticated user' do
|
||||||
|
it 'returns unauthorized' do
|
||||||
|
company = create(:company, account: account)
|
||||||
|
delete "/api/v1/accounts/#{account.id}/companies/#{company.id}"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when it is an authenticated administrator' do
|
context 'when it is an authenticated administrator' do
|
||||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
let(:company) { create(:company, account: account) }
|
let(:company) { create(:company, account: account) }
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ RSpec.describe Contacts::CompanyAssociationService, type: :service do
|
|||||||
contact.reload
|
contact.reload
|
||||||
expect(contact.company).to eq(existing_company)
|
expect(contact.company).to eq(existing_company)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'increments company contacts_count when associating contact' do
|
||||||
|
# Create contact without email to avoid auto-association
|
||||||
|
contact = create(:contact, email: nil, account: account)
|
||||||
|
# Manually set email to bypass callbacks
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
contact.update_column(:email, 'jane@techcorp.com')
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
valid_email_address = instance_double(ValidEmail2::Address, valid?: true, disposable_domain?: false)
|
||||||
|
allow(ValidEmail2::Address).to receive(:new).with('jane@techcorp.com').and_return(valid_email_address)
|
||||||
|
allow(EmailProviderInfo).to receive(:call).with('jane@techcorp.com').and_return(nil)
|
||||||
|
|
||||||
|
service.associate_company_from_email(contact)
|
||||||
|
|
||||||
|
contact.reload
|
||||||
|
expect(contact.company).to be_present
|
||||||
|
expect(contact.company.contacts_count).to eq(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when contact already has a company' do
|
context 'when contact already has a company' do
|
||||||
|
|||||||
Reference in New Issue
Block a user