From da61c9855b3035da4de731f682c60e37d0c64dfa Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 11:57:39 +0530 Subject: [PATCH 1/6] feat: add test for sessions controller --- .../devise_overrides/sessions_controller.rb | 24 +++--- .../session_controller_spec.rb | 80 ++++++++++++++----- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb index 17e3ad682..b5a6a2ac2 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/sessions_controller.rb @@ -1,12 +1,14 @@ module Enterprise::DeviseOverrides::SessionsController def create - check_saml_user + if saml_user_attempting_password_login? + render json: { + success: false, + errors: [I18n.t('messages.login_saml_user')] + }, status: :unauthorized + return + end + super - rescue CustomExceptions::Base => e - render json: { - success: false, - errors: [e.message] - }, status: e.http_status_code end def render_create_success @@ -35,14 +37,14 @@ module Enterprise::DeviseOverrides::SessionsController private - def check_saml_user - return if params[:email].blank? + def saml_user_attempting_password_login? + return false if params[:email].blank? user = User.from_email(params[:email]) - return unless user&.provider == 'saml' + return false unless user&.provider == 'saml' - return if params[:sso_auth_token].present? && user.valid_sso_auth_token?(params[:sso_auth_token]) + return false if params[:sso_auth_token].present? && user.valid_sso_auth_token?(params[:sso_auth_token]) - raise CustomExceptions::Base.new(I18n.t('messages.login_saml_user'), :unauthorized) + true end end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb index 89f8794ee..8e04af776 100644 --- a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb @@ -5,32 +5,70 @@ RSpec.describe 'Enterprise Audit API', type: :request do let!(:user) { create(:user, password: 'Password1!', account: account) } describe 'POST /sign_in' do - it 'creates a sign_in audit event wwith valid credentials' do - params = { email: user.email, password: 'Password1!' } + context 'with SAML user attempting password login' do + let!(:saml_settings) { create(:account_saml_settings, account: account) } + let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } - expect do - post new_user_session_url, - params: params, - as: :json - end.to change(Enterprise::AuditLog, :count).by(1) + it 'prevents login and returns SAML authentication error' do + params = { email: saml_user.email, password: 'Password1!' } - expect(response).to have_http_status(:success) - expect(response.body).to include(user.email) + post new_user_session_url, params: params, as: :json - # Check if the sign_in event is created - user.reload - expect(user.audits.last.action).to eq('sign_in') - expect(user.audits.last.associated_id).to eq(account.id) - expect(user.audits.last.associated_type).to eq('Account') + expect(response).to have_http_status(:unauthorized) + json_response = JSON.parse(response.body) + expect(json_response['success']).to eq(false) + expect(json_response['errors']).to include(I18n.t('messages.login_saml_user')) + end + + it 'allows login with valid SSO token' do + valid_token = saml_user.generate_sso_auth_token + params = { email: saml_user.email, sso_auth_token: valid_token, password: 'Password1!' } + + expect do + post new_user_session_url, params: params, as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(saml_user.email) + end end - it 'will not create a sign_in audit event with invalid credentials' do - params = { email: user.email, password: 'invalid' } - expect do - post new_user_session_url, - params: params, - as: :json - end.not_to change(Enterprise::AuditLog, :count) + context 'with regular user credentials' do + it 'creates a sign_in audit event wwith valid credentials' do + params = { email: user.email, password: 'Password1!' } + + expect do + post new_user_session_url, + params: params, + as: :json + end.to change(Enterprise::AuditLog, :count).by(1) + + expect(response).to have_http_status(:success) + expect(response.body).to include(user.email) + + # Check if the sign_in event is created + user.reload + expect(user.audits.last.action).to eq('sign_in') + expect(user.audits.last.associated_id).to eq(account.id) + expect(user.audits.last.associated_type).to eq('Account') + end + + it 'will not create a sign_in audit event with invalid credentials' do + params = { email: user.email, password: 'invalid' } + expect do + post new_user_session_url, + params: params, + as: :json + end.not_to change(Enterprise::AuditLog, :count) + end + end + + context 'with blank email' do + it 'skips SAML check and processes normally' do + params = { email: '', password: 'Password1!' } + post new_user_session_url, params: params, as: :json + expect(response).to have_http_status(:unauthorized) + end end end From 55633ab063b4e267f11a99e88dcd08ac9f7328d9 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 10 Sep 2025 12:07:21 +0530 Subject: [PATCH 2/6] feat: Agent assignment policy index page with CRUD actions (#12373) # Pull Request Template ## Description This PR incudes new Agent assignment policy index page with CRUD actions. Fixes https://linear.app/chatwoot/issue/CW-5570/feat-assignment-policy-index-page-with-actions ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Loom https://www.loom.com/share/17ab5ceca4854f179628a3b53f347e5a?sid=cb64e881-57fd-4ae1-921b-7648653cca33 ### Screenshots **Light mode** image **Dark mode** image image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] 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 - [ ] 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: Muhsin Keloth --- .../dashboard/api/assignmentPolicies.js | 36 +++ .../api/specs/assignmentPolicies.spec.js | 70 +++++ .../AssignmentPolicyCard.story.vue | 104 +++++++ .../AssignmentPolicyCard.vue | 133 +++++++++ .../components/CardPopover.vue | 103 +++++++ .../components/story/CardPopover.story.vue | 45 +++ app/javascript/dashboard/helper/commons.js | 15 + .../dashboard/helper/specs/commons.spec.js | 49 ++++ .../dashboard/i18n/locale/en/settings.json | 25 ++ .../dashboard/settings/SettingsLayout.vue | 4 +- .../settings/assignmentPolicy/Index.vue | 2 +- .../assignmentPolicy.routes.js | 10 + .../pages/AgentAssignmentIndexPage.vue | 118 ++++++++ .../components/ConfirmDeletePolicyDialog.vue | 50 ++++ app/javascript/dashboard/store/index.js | 2 + .../store/modules/assignmentPolicies.js | 156 ++++++++++ .../specs/assignmentPolicies/actions.spec.js | 214 ++++++++++++++ .../specs/assignmentPolicies/fixtures.js | 57 ++++ .../specs/assignmentPolicies/getters.spec.js | 49 ++++ .../assignmentPolicies/mutations.spec.js | 267 ++++++++++++++++++ .../dashboard/store/mutation-types.js | 10 + .../_assignment_policy.json.jbuilder | 1 + 22 files changed, 1517 insertions(+), 3 deletions(-) create mode 100644 app/javascript/dashboard/api/assignmentPolicies.js create mode 100644 app/javascript/dashboard/api/specs/assignmentPolicies.spec.js create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue create mode 100644 app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue create mode 100644 app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue create mode 100644 app/javascript/dashboard/store/modules/assignmentPolicies.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js create mode 100644 app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js diff --git a/app/javascript/dashboard/api/assignmentPolicies.js b/app/javascript/dashboard/api/assignmentPolicies.js new file mode 100644 index 000000000..e6baca97a --- /dev/null +++ b/app/javascript/dashboard/api/assignmentPolicies.js @@ -0,0 +1,36 @@ +/* global axios */ + +import ApiClient from './ApiClient'; + +class AssignmentPolicies extends ApiClient { + constructor() { + super('assignment_policies', { accountScoped: true }); + } + + getInboxes(policyId) { + return axios.get(`${this.url}/${policyId}/inboxes`); + } + + setInboxPolicy(inboxId, policyId) { + return axios.post( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy`, + { + assignment_policy_id: policyId, + } + ); + } + + getInboxPolicy(inboxId) { + return axios.get( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } + + removeInboxPolicy(inboxId) { + return axios.delete( + `/api/v1/accounts/${this.accountIdFromRoute}/inboxes/${inboxId}/assignment_policy` + ); + } +} + +export default new AssignmentPolicies(); diff --git a/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js new file mode 100644 index 000000000..8d0aea7d0 --- /dev/null +++ b/app/javascript/dashboard/api/specs/assignmentPolicies.spec.js @@ -0,0 +1,70 @@ +import assignmentPolicies from '../assignmentPolicies'; +import ApiClient from '../ApiClient'; + +describe('#AssignmentPoliciesAPI', () => { + it('creates correct instance', () => { + expect(assignmentPolicies).toBeInstanceOf(ApiClient); + expect(assignmentPolicies).toHaveProperty('get'); + expect(assignmentPolicies).toHaveProperty('show'); + expect(assignmentPolicies).toHaveProperty('create'); + expect(assignmentPolicies).toHaveProperty('update'); + expect(assignmentPolicies).toHaveProperty('delete'); + expect(assignmentPolicies).toHaveProperty('getInboxes'); + expect(assignmentPolicies).toHaveProperty('setInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('getInboxPolicy'); + expect(assignmentPolicies).toHaveProperty('removeInboxPolicy'); + }); + + describe('API calls', () => { + const originalAxios = window.axios; + const axiosMock = { + get: vi.fn(() => Promise.resolve()), + post: vi.fn(() => Promise.resolve()), + delete: vi.fn(() => Promise.resolve()), + }; + + beforeEach(() => { + window.axios = axiosMock; + // Mock accountIdFromRoute + Object.defineProperty(assignmentPolicies, 'accountIdFromRoute', { + get: () => '1', + configurable: true, + }); + }); + + afterEach(() => { + window.axios = originalAxios; + }); + + it('#getInboxes', () => { + assignmentPolicies.getInboxes(123); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/assignment_policies/123/inboxes' + ); + }); + + it('#setInboxPolicy', () => { + assignmentPolicies.setInboxPolicy(456, 123); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy', + { + assignment_policy_id: 123, + } + ); + }); + + it('#getInboxPolicy', () => { + assignmentPolicies.getInboxPolicy(456); + expect(axiosMock.get).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + + it('#removeInboxPolicy', () => { + assignmentPolicies.removeInboxPolicy(456); + expect(axiosMock.delete).toHaveBeenCalledWith( + '/api/v1/accounts/1/inboxes/456/assignment_policy' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue new file mode 100644 index 000000000..cd6f1d49b --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.story.vue @@ -0,0 +1,104 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue new file mode 100644 index 000000000..fe9965777 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/AssignmentPolicyCard/AssignmentPolicyCard.vue @@ -0,0 +1,133 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue new file mode 100644 index 000000000..013e6d5fe --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/CardPopover.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue new file mode 100644 index 000000000..d010c16f9 --- /dev/null +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/story/CardPopover.story.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/javascript/dashboard/helper/commons.js b/app/javascript/dashboard/helper/commons.js index b12d1aa3d..3be7538bb 100644 --- a/app/javascript/dashboard/helper/commons.js +++ b/app/javascript/dashboard/helper/commons.js @@ -96,3 +96,18 @@ export const sanitizeVariableSearchKey = (searchKey = '') => { .replace(/,/g, '') // remove commas .trim(); }; + +/** + * Convert underscore-separated string to title case. + * Eg. "round_robin" => "Round Robin" + * @param {string} str + * @returns {string} + */ +export const formatToTitleCase = str => { + return ( + str + ?.replace(/_/g, ' ') + .replace(/\b\w/g, l => l.toUpperCase()) + .trim() || '' + ); +}; diff --git a/app/javascript/dashboard/helper/specs/commons.spec.js b/app/javascript/dashboard/helper/specs/commons.spec.js index 466cdcf45..d892d3a94 100644 --- a/app/javascript/dashboard/helper/specs/commons.spec.js +++ b/app/javascript/dashboard/helper/specs/commons.spec.js @@ -5,6 +5,7 @@ import { convertToCategorySlug, convertToPortalSlug, sanitizeVariableSearchKey, + formatToTitleCase, } from '../commons'; describe('#getTypingUsersText', () => { @@ -142,3 +143,51 @@ describe('sanitizeVariableSearchKey', () => { expect(sanitizeVariableSearchKey()).toBe(''); }); }); + +describe('formatToTitleCase', () => { + it('converts underscore-separated string to title case', () => { + expect(formatToTitleCase('round_robin')).toBe('Round Robin'); + }); + + it('converts single word to title case', () => { + expect(formatToTitleCase('priority')).toBe('Priority'); + }); + + it('converts multiple underscores to title case', () => { + expect(formatToTitleCase('auto_assignment_policy')).toBe( + 'Auto Assignment Policy' + ); + }); + + it('handles already capitalized words', () => { + expect(formatToTitleCase('HIGH_PRIORITY')).toBe('HIGH PRIORITY'); + }); + + it('handles mixed case with underscores', () => { + expect(formatToTitleCase('first_Name_last')).toBe('First Name Last'); + }); + + it('handles empty string', () => { + expect(formatToTitleCase('')).toBe(''); + }); + + it('handles null input', () => { + expect(formatToTitleCase(null)).toBe(''); + }); + + it('handles undefined input', () => { + expect(formatToTitleCase(undefined)).toBe(''); + }); + + it('handles string without underscores', () => { + expect(formatToTitleCase('hello')).toBe('Hello'); + }); + + it('handles string with numbers', () => { + expect(formatToTitleCase('priority_1_high')).toBe('Priority 1 High'); + }); + + it('handles leading and trailing underscores', () => { + expect(formatToTitleCase('_leading_trailing_')).toBe('Leading Trailing'); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 2e24bace4..c0367ea9f 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -451,6 +451,31 @@ "Add agents to a policy - one policy per agent" ] } + }, + "AGENT_ASSIGNMENT_POLICY": { + "INDEX": { + "HEADER": { + "TITLE": "Assignment policy", + "CREATE_POLICY": "New policy" + }, + "CARD": { + "ORDER": "Order", + "PRIORITY": "Priority", + "ACTIVE": "Active", + "INACTIVE": "Inactive", + "POPOVER": "Added inboxes", + "EDIT": "Edit" + }, + "NO_RECORDS_FOUND": "No assignment policies found" + }, + "DELETE_POLICY": { + "TITLE": "Delete policy", + "DESCRIPTION": "Are you sure you want to delete this policy? This action cannot be undone.", + "CONFIRM_BUTTON_LABEL": "Delete", + "CANCEL_BUTTON_LABEL": "Cancel", + "SUCCESS_MESSAGE": "Assignment policy deleted successfully", + "ERROR_MESSAGE": "Failed to delete assignment policy" + } } } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue index 44bad28c1..9e34cb384 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/SettingsLayout.vue @@ -23,7 +23,7 @@ defineProps({
-
+
@@ -37,6 +37,6 @@ defineProps({ -
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue index 6b0f88033..b41d9990a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/Index.vue @@ -11,7 +11,7 @@ const { t } = useI18n(); const agentAssignments = computed(() => [ { - key: 'assignment_policy', + key: 'agent_assignment_policy_index', title: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.TITLE'), description: t('ASSIGNMENT_POLICY.INDEX.ASSIGNMENT_POLICY.DESCRIPTION'), features: [ diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js index 2d62674b0..6b4c35da3 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/assignmentPolicy.routes.js @@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from '../../../../featureFlags'; import { frontendURL } from '../../../../helper/URLHelper'; import SettingsWrapper from '../SettingsWrapper.vue'; import AssignmentPolicyIndex from './Index.vue'; +import AgentAssignmentIndex from './pages/AgentAssignmentIndexPage.vue'; export default { routes: [ @@ -24,6 +25,15 @@ export default { permissions: ['administrator'], }, }, + { + path: 'assignment', + name: 'agent_assignment_policy_index', + component: AgentAssignmentIndex, + meta: { + featureFlag: FEATURE_FLAGS.ASSIGNMENT_V2, + permissions: ['administrator'], + }, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue new file mode 100644 index 000000000..e931d6bbd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentIndexPage.vue @@ -0,0 +1,118 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue new file mode 100644 index 000000000..8cf13bcd5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/ConfirmDeletePolicyDialog.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 5a020dda6..291031ffd 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -3,6 +3,7 @@ import { createStore } from 'vuex'; import accounts from './modules/accounts'; import agentBots from './modules/agentBots'; import agents from './modules/agents'; +import assignmentPolicies from './modules/assignmentPolicies'; import articles from './modules/helpCenterArticles'; import attributes from './modules/attributes'; import auditlogs from './modules/auditlogs'; @@ -63,6 +64,7 @@ export default createStore({ accounts, agentBots, agents, + assignmentPolicies, articles, attributes, auditlogs, diff --git a/app/javascript/dashboard/store/modules/assignmentPolicies.js b/app/javascript/dashboard/store/modules/assignmentPolicies.js new file mode 100644 index 000000000..80c903c4e --- /dev/null +++ b/app/javascript/dashboard/store/modules/assignmentPolicies.js @@ -0,0 +1,156 @@ +import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; +import types from '../mutation-types'; +import AssignmentPoliciesAPI from '../../api/assignmentPolicies'; +import { throwErrorMessage } from '../utils/api'; +import camelcaseKeys from 'camelcase-keys'; + +export const state = { + records: [], + uiFlags: { + isFetching: false, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + inboxUiFlags: { + isFetching: false, + }, +}; + +export const getters = { + getAssignmentPolicies(_state) { + return _state.records; + }, + getUIFlags(_state) { + return _state.uiFlags; + }, + getInboxUiFlags(_state) { + return _state.inboxUiFlags; + }, + getAssignmentPolicyById: _state => id => { + return _state.records.find(record => record.id === Number(id)) || {}; + }, +}; + +export const actions = { + get: async function get({ commit }) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }); + try { + const response = await AssignmentPoliciesAPI.get(); + commit(types.SET_ASSIGNMENT_POLICIES, camelcaseKeys(response.data)); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }); + } + }, + + show: async function show({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }); + try { + const response = await AssignmentPoliciesAPI.show(policyId); + const policy = camelcaseKeys(response.data); + commit(types.EDIT_ASSIGNMENT_POLICY, policy); + } catch (error) { + throwErrorMessage(error); + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }); + } + }, + + create: async function create({ commit }, policyObj) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }); + try { + const response = await AssignmentPoliciesAPI.create(policyObj); + commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }); + } + }, + + update: async function update({ commit }, { id, ...policyParams }) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }); + try { + const response = await AssignmentPoliciesAPI.update(id, policyParams); + commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data)); + return response.data; + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }); + } + }, + + delete: async function deletePolicy({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }); + try { + await AssignmentPoliciesAPI.delete(policyId); + commit(types.DELETE_ASSIGNMENT_POLICY, policyId); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }); + } + }, + + getInboxes: async function getInboxes({ commit }, policyId) { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }); + try { + const response = await AssignmentPoliciesAPI.getInboxes(policyId); + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES, { + policyId, + inboxes: camelcaseKeys(response.data.inboxes), + }); + } catch (error) { + throwErrorMessage(error); + throw error; + } finally { + commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { + isFetching: false, + }); + } + }, +}; + +export const mutations = { + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG](_state, data) { + _state.uiFlags = { + ..._state.uiFlags, + ...data, + }; + }, + + [types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set, + [types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create, + [types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.update, + [types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy, + + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) { + _state.inboxUiFlags = { + ..._state.inboxUiFlags, + ...data, + }; + }, + + [types.SET_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxes }) { + const policy = _state.records.find(p => p.id === policyId); + if (policy) { + policy.inboxes = inboxes; + } + }, +}; + +export default { + namespaced: true, + state, + getters, + actions, + mutations, +}; diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js new file mode 100644 index 000000000..5358144e8 --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/actions.spec.js @@ -0,0 +1,214 @@ +import axios from 'axios'; +import { actions } from '../../assignmentPolicies'; +import types from '../../../mutation-types'; +import assignmentPoliciesList, { camelCaseFixtures } from './fixtures'; +import camelcaseKeys from 'camelcase-keys'; + +const commit = vi.fn(); + +global.axios = axios; +vi.mock('axios'); +vi.mock('camelcase-keys'); +vi.mock('../../../utils/api'); + +describe('#actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('#get', () => { + it('sends correct actions if API is success', async () => { + axios.get.mockResolvedValue({ data: assignmentPoliciesList }); + camelcaseKeys.mockReturnValue(camelCaseFixtures); + + await actions.get({ commit }); + + expect(camelcaseKeys).toHaveBeenCalledWith(assignmentPoliciesList); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES, camelCaseFixtures], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Incorrect header' }); + + await actions.get({ commit }); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false }], + ]); + }); + }); + + describe('#show', () => { + it('sends correct actions if API is success', async () => { + const policyData = assignmentPoliciesList[0]; + const camelCasedPolicy = camelCaseFixtures[0]; + + axios.get.mockResolvedValue({ data: policyData }); + camelcaseKeys.mockReturnValue(camelCasedPolicy); + + await actions.show({ commit }, 1); + + expect(camelcaseKeys).toHaveBeenCalledWith(policyData); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.EDIT_ASSIGNMENT_POLICY, camelCasedPolicy], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.get.mockRejectedValue({ message: 'Not found' }); + + await actions.show({ commit }, 1); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false }], + ]); + }); + }); + + describe('#create', () => { + it('sends correct actions if API is success', async () => { + const newPolicy = assignmentPoliciesList[0]; + const camelCasedData = camelCaseFixtures[0]; + + axios.post.mockResolvedValue({ data: newPolicy }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.create({ commit }, newPolicy); + + expect(camelcaseKeys).toHaveBeenCalledWith(newPolicy); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }], + [types.ADD_ASSIGNMENT_POLICY, camelCasedData], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }], + ]); + expect(result).toEqual(newPolicy); + }); + + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue(new Error('Validation error')); + + await expect(actions.create({ commit }, {})).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false }], + ]); + }); + }); + + describe('#update', () => { + it('sends correct actions if API is success', async () => { + const updateParams = { id: 1, name: 'Updated Policy' }; + const responseData = { + ...assignmentPoliciesList[0], + name: 'Updated Policy', + }; + const camelCasedData = { + ...camelCaseFixtures[0], + name: 'Updated Policy', + }; + + axios.patch.mockResolvedValue({ data: responseData }); + camelcaseKeys.mockReturnValue(camelCasedData); + + const result = await actions.update({ commit }, updateParams); + + expect(camelcaseKeys).toHaveBeenCalledWith(responseData); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }], + [types.EDIT_ASSIGNMENT_POLICY, camelCasedData], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + expect(result).toEqual(responseData); + }); + + it('sends correct actions if API is error', async () => { + axios.patch.mockRejectedValue(new Error('Validation error')); + + await expect( + actions.update({ commit }, { id: 1, name: 'Test' }) + ).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false }], + ]); + }); + }); + + describe('#delete', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + axios.delete.mockResolvedValue({}); + + await actions.delete({ commit }, policyId); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }], + [types.DELETE_ASSIGNMENT_POLICY, policyId], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue(new Error('Not found')); + + await expect(actions.delete({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true }], + [types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false }], + ]); + }); + }); + + describe('#getInboxes', () => { + it('sends correct actions if API is success', async () => { + const policyId = 1; + const inboxData = { + inboxes: [ + { id: 1, name: 'Support' }, + { id: 2, name: 'Sales' }, + ], + }; + const camelCasedInboxes = [ + { id: 1, name: 'Support' }, + { id: 2, name: 'Sales' }, + ]; + + axios.get.mockResolvedValue({ data: inboxData }); + camelcaseKeys.mockReturnValue(camelCasedInboxes); + + await actions.getInboxes({ commit }, policyId); + + expect(camelcaseKeys).toHaveBeenCalledWith(inboxData.inboxes); + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }], + [ + types.SET_ASSIGNMENT_POLICIES_INBOXES, + { policyId, inboxes: camelCasedInboxes }, + ], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }], + ]); + }); + + it('sends correct actions if API fails', async () => { + axios.get.mockRejectedValue(new Error('API Error')); + + await expect(actions.getInboxes({ commit }, 1)).rejects.toThrow(Error); + + expect(commit.mock.calls).toEqual([ + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true }], + [types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: false }], + ]); + }); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js new file mode 100644 index 000000000..1b5ed25af --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/fixtures.js @@ -0,0 +1,57 @@ +export default [ + { + id: 1, + name: 'Round Robin Policy', + description: 'Distributes conversations evenly among agents', + assignment_order: 'round_robin', + conversation_priority: 'earliest_created', + fair_distribution_limit: 100, + fair_distribution_window: 3600, + enabled: true, + assigned_inbox_count: 3, + created_at: 1704110400, + updated_at: 1704110400, + }, + { + id: 2, + name: 'Balanced Policy', + description: 'Assigns conversations based on agent capacity', + assignment_order: 'balanced', + conversation_priority: 'longest_waiting', + fair_distribution_limit: 50, + fair_distribution_window: 1800, + enabled: false, + assigned_inbox_count: 1, + created_at: 1704114000, + updated_at: 1704114000, + }, +]; + +export const camelCaseFixtures = [ + { + id: 1, + name: 'Round Robin Policy', + description: 'Distributes conversations evenly among agents', + assignmentOrder: 'round_robin', + conversationPriority: 'earliest_created', + fairDistributionLimit: 100, + fairDistributionWindow: 3600, + enabled: true, + assignedInboxCount: 3, + createdAt: 1704110400, + updatedAt: 1704110400, + }, + { + id: 2, + name: 'Balanced Policy', + description: 'Assigns conversations based on agent capacity', + assignmentOrder: 'balanced', + conversationPriority: 'longest_waiting', + fairDistributionLimit: 50, + fairDistributionWindow: 1800, + enabled: false, + assignedInboxCount: 1, + createdAt: 1704114000, + updatedAt: 1704114000, + }, +]; diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js new file mode 100644 index 000000000..7e0e2041c --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/getters.spec.js @@ -0,0 +1,49 @@ +import { getters } from '../../assignmentPolicies'; +import assignmentPoliciesList from './fixtures'; + +describe('#getters', () => { + it('getAssignmentPolicies', () => { + const state = { records: assignmentPoliciesList }; + expect(getters.getAssignmentPolicies(state)).toEqual( + assignmentPoliciesList + ); + }); + + it('getUIFlags', () => { + const state = { + uiFlags: { + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }, + }; + expect(getters.getUIFlags(state)).toEqual({ + isFetching: true, + isFetchingItem: false, + isCreating: false, + isUpdating: false, + isDeleting: false, + }); + }); + + it('getInboxUiFlags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + }, + }; + expect(getters.getInboxUiFlags(state)).toEqual({ + isFetching: false, + }); + }); + + it('getAssignmentPolicyById', () => { + const state = { records: assignmentPoliciesList }; + expect(getters.getAssignmentPolicyById(state)(1)).toEqual( + assignmentPoliciesList[0] + ); + expect(getters.getAssignmentPolicyById(state)(3)).toEqual({}); + }); +}); diff --git a/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js new file mode 100644 index 000000000..58d5527ca --- /dev/null +++ b/app/javascript/dashboard/store/modules/specs/assignmentPolicies/mutations.spec.js @@ -0,0 +1,267 @@ +import { mutations } from '../../assignmentPolicies'; +import types from '../../../mutation-types'; +import assignmentPoliciesList from './fixtures'; + +describe('#mutations', () => { + describe('#SET_ASSIGNMENT_POLICIES_UI_FLAG', () => { + it('sets single ui flag', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: false, + }); + }); + + it('sets multiple ui flags', () => { + const state = { + uiFlags: { + isFetching: false, + isCreating: false, + isUpdating: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](state, { + isFetching: true, + isCreating: true, + }); + + expect(state.uiFlags).toEqual({ + isFetching: true, + isCreating: true, + isUpdating: false, + }); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES', () => { + it('sets assignment policies records', () => { + const state = { records: [] }; + + mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList); + + expect(state.records).toEqual(assignmentPoliciesList); + }); + + it('replaces existing records', () => { + const state = { records: [{ id: 999, name: 'Old Policy' }] }; + + mutations[types.SET_ASSIGNMENT_POLICIES](state, assignmentPoliciesList); + + expect(state.records).toEqual(assignmentPoliciesList); + }); + }); + + describe('#ADD_ASSIGNMENT_POLICY', () => { + it('adds new policy to empty records', () => { + const state = { records: [] }; + + mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[0]); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + + it('adds new policy to existing records', () => { + const state = { records: [assignmentPoliciesList[0]] }; + + mutations[types.ADD_ASSIGNMENT_POLICY](state, assignmentPoliciesList[1]); + + expect(state.records).toEqual([ + assignmentPoliciesList[0], + assignmentPoliciesList[1], + ]); + }); + }); + + describe('#EDIT_ASSIGNMENT_POLICY', () => { + it('updates existing policy by id', () => { + const state = { + records: [ + { ...assignmentPoliciesList[0] }, + { ...assignmentPoliciesList[1] }, + ], + }; + + const updatedPolicy = { + ...assignmentPoliciesList[0], + name: 'Updated Policy Name', + description: 'Updated Description', + }; + + mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + expect(state.records[1]).toEqual(assignmentPoliciesList[1]); + }); + + it('updates policy with camelCase properties', () => { + const camelCasePolicy = { + id: 1, + name: 'Camel Case Policy', + assignmentOrder: 'round_robin', + conversationPriority: 'earliest_created', + }; + + const state = { + records: [camelCasePolicy], + }; + + const updatedPolicy = { + ...camelCasePolicy, + name: 'Updated Camel Case', + assignmentOrder: 'balanced', + }; + + mutations[types.EDIT_ASSIGNMENT_POLICY](state, updatedPolicy); + + expect(state.records[0]).toEqual(updatedPolicy); + }); + + it('does nothing if policy id not found', () => { + const state = { + records: [assignmentPoliciesList[0]], + }; + + const nonExistentPolicy = { + id: 999, + name: 'Non-existent', + }; + + const originalRecords = [...state.records]; + mutations[types.EDIT_ASSIGNMENT_POLICY](state, nonExistentPolicy); + + expect(state.records).toEqual(originalRecords); + }); + }); + + describe('#DELETE_ASSIGNMENT_POLICY', () => { + it('deletes policy by id', () => { + const state = { + records: [assignmentPoliciesList[0], assignmentPoliciesList[1]], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1); + + expect(state.records).toEqual([assignmentPoliciesList[1]]); + }); + + it('does nothing if id not found', () => { + const state = { + records: [assignmentPoliciesList[0]], + }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 999); + + expect(state.records).toEqual([assignmentPoliciesList[0]]); + }); + + it('handles empty records', () => { + const state = { records: [] }; + + mutations[types.DELETE_ASSIGNMENT_POLICY](state, 1); + + expect(state.records).toEqual([]); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', () => { + it('sets inbox ui flags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.inboxUiFlags).toEqual({ + isFetching: true, + }); + }); + + it('merges with existing flags', () => { + const state = { + inboxUiFlags: { + isFetching: false, + isLoading: true, + }, + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](state, { + isFetching: true, + }); + + expect(state.inboxUiFlags).toEqual({ + isFetching: true, + isLoading: true, + }); + }); + }); + + describe('#SET_ASSIGNMENT_POLICIES_INBOXES', () => { + it('sets inboxes for existing policy', () => { + const mockInboxes = [ + { id: 1, name: 'Support Inbox' }, + { id: 2, name: 'Sales Inbox' }, + ]; + + const state = { + records: [ + { id: 1, name: 'Policy 1', inboxes: [] }, + { id: 2, name: 'Policy 2', inboxes: [] }, + ], + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxes: mockInboxes, + }); + + expect(state.records[0].inboxes).toEqual(mockInboxes); + expect(state.records[1].inboxes).toEqual([]); + }); + + it('replaces existing inboxes', () => { + const oldInboxes = [{ id: 99, name: 'Old Inbox' }]; + const newInboxes = [{ id: 1, name: 'New Inbox' }]; + + const state = { + records: [{ id: 1, name: 'Policy 1', inboxes: oldInboxes }], + }; + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 1, + inboxes: newInboxes, + }); + + expect(state.records[0].inboxes).toEqual(newInboxes); + }); + + it('does nothing if policy not found', () => { + const state = { + records: [{ id: 1, name: 'Policy 1', inboxes: [] }], + }; + + const originalState = JSON.parse(JSON.stringify(state)); + + mutations[types.SET_ASSIGNMENT_POLICIES_INBOXES](state, { + policyId: 999, + inboxes: [{ id: 1, name: 'Test' }], + }); + + expect(state).toEqual(originalState); + }); + }); +}); diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index a63fec2d1..a6fbefa17 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -348,4 +348,14 @@ export default { SET_TEAM_CONVERSATION_METRIC: 'SET_TEAM_CONVERSATION_METRIC', TOGGLE_TEAM_CONVERSATION_METRIC_LOADING: 'TOGGLE_TEAM_CONVERSATION_METRIC_LOADING', + + // Assignment Policies + SET_ASSIGNMENT_POLICIES_UI_FLAG: 'SET_ASSIGNMENT_POLICIES_UI_FLAG', + SET_ASSIGNMENT_POLICIES: 'SET_ASSIGNMENT_POLICIES', + ADD_ASSIGNMENT_POLICY: 'ADD_ASSIGNMENT_POLICY', + EDIT_ASSIGNMENT_POLICY: 'EDIT_ASSIGNMENT_POLICY', + DELETE_ASSIGNMENT_POLICY: 'DELETE_ASSIGNMENT_POLICY', + SET_ASSIGNMENT_POLICIES_INBOXES: 'SET_ASSIGNMENT_POLICIES_INBOXES', + SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG: + 'SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG', }; diff --git a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder index b48307a94..cf09a2949 100644 --- a/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder +++ b/app/views/api/v1/accounts/assignment_policies/_assignment_policy.json.jbuilder @@ -6,5 +6,6 @@ json.conversation_priority assignment_policy.conversation_priority json.fair_distribution_limit assignment_policy.fair_distribution_limit json.fair_distribution_window assignment_policy.fair_distribution_window json.enabled assignment_policy.enabled +json.assigned_inbox_count assignment_policy.inboxes.count json.created_at assignment_policy.created_at.to_i json.updated_at assignment_policy.updated_at.to_i From 5aaf331851e23233efe7389fd8487c6cf2357461 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 12:25:27 +0530 Subject: [PATCH 3/6] feat: add omniauth tests --- .../omniauth_callbacks_controller_spec.rb | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb new file mode 100644 index 000000000..af67e3009 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do + let!(:account) { create(:account) } + let(:saml_settings) { create(:account_saml_settings, account: account) } + + def set_saml_config(email = 'test@example.com') + OmniAuth.config.test_mode = true + OmniAuth.config.mock_auth[:saml] = OmniAuth::AuthHash.new( + provider: 'saml', + uid: '123545', + info: { + name: 'Test User', + email: email + } + ) + end + + before do + allow(ChatwootApp).to receive(:enterprise?).and_return(true) + account.enable_features!('saml') + saml_settings + end + + describe '#saml callback' do + it 'creates new user and logs them in' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + set_saml_config('new_user@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + # expect redirect to login with SSO token + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + + # verify user was created + user = User.from_email('new_user@example.com') + expect(user).to be_present + expect(user.provider).to eq('saml') + end + end + + it 'logs in existing user' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + create(:user, email: 'existing@example.com', account: account) + set_saml_config('existing@example.com') + + get "/omniauth/saml/callback?account_id=#{account.id}" + + # expect a 302 redirect to auth/saml/callback + expect(response).to redirect_to('http://www.example.com/auth/saml/callback') + follow_redirect! + + expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$}) + end + end + end +end From 43d2b37d645a617a2afc3fb27e937eef7066507d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 12:29:54 +0530 Subject: [PATCH 4/6] feat: add specs for passwords controller --- .../devise_overrides/passwords_controller.rb | 19 ++++++---- .../passwords_controller_spec.rb | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb diff --git a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb index e2bbb8cb6..d84ac1d71 100644 --- a/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb +++ b/enterprise/app/controllers/enterprise/devise_overrides/passwords_controller.rb @@ -1,19 +1,24 @@ module Enterprise::DeviseOverrides::PasswordsController def create - check_saml_user + if saml_user_attempting_password_reset? + render json: { + success: false, + errors: [I18n.t('messages.reset_password_saml_user')] + }, status: :forbidden + return + end + super - rescue CustomExceptions::Base => e - build_response(e.message, e.http_status_code) end private - def check_saml_user - return if params[:email].blank? + def saml_user_attempting_password_reset? + return false if params[:email].blank? user = User.from_email(params[:email]) - return unless user&.provider == 'saml' + return false unless user&.provider == 'saml' - raise CustomExceptions::Base.new(I18n.t('messages.reset_password_saml_user'), :forbidden) + true end end diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb new file mode 100644 index 000000000..23b498be8 --- /dev/null +++ b/spec/enterprise/controllers/enterprise/devise_overrides/passwords_controller_spec.rb @@ -0,0 +1,36 @@ +require 'rails_helper' + +RSpec.describe 'Enterprise Passwords Controller', type: :request do + let!(:account) { create(:account) } + + describe 'POST /auth/password' do + context 'with SAML user email' do + let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } + + it 'prevents password reset and returns forbidden with custom error message' do + params = { email: saml_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:forbidden) + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(false) + expect(json_response['errors']).to include(I18n.t('messages.reset_password_saml_user')) + end + end + + context 'with non-SAML user email' do + let!(:regular_user) { create(:user, email: 'regular@example.com', provider: 'email', account: account) } + + it 'allows password reset for non-SAML users' do + params = { email: regular_user.email, redirect_url: 'http://test.host' } + + post user_password_path, params: params, as: :json + + expect(response).to have_http_status(:ok) + json_response = JSON.parse(response.body) + expect(json_response['message']).to be_present + end + end + end +end From 1a78b686255a38de760e82c44cc835fc1c5c59df Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 12:30:53 +0530 Subject: [PATCH 5/6] style: Remove redundant sort --- config/application.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/application.rb b/config/application.rb index b44493b5c..7fd1b94ba 100644 --- a/config/application.rb +++ b/config/application.rb @@ -49,7 +49,7 @@ module Chatwoot # Load enterprise initializers alongside standard initializers enterprise_initializers = Rails.root.join('enterprise/config/initializers') - Dir[enterprise_initializers.join('**/*.rb')].sort.each { |f| require f } if enterprise_initializers.exist? + Dir[enterprise_initializers.join('**/*.rb')].each { |f| require f } if enterprise_initializers.exist? # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers From 9ded1180595ee8c9edf9232d034537c30e71c7fc Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 10 Sep 2025 12:31:38 +0530 Subject: [PATCH 6/6] style: remove let! --- .../devise_overrides/session_controller_spec.rb | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb index 8e04af776..08c69c840 100644 --- a/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/devise_overrides/session_controller_spec.rb @@ -6,8 +6,13 @@ RSpec.describe 'Enterprise Audit API', type: :request do describe 'POST /sign_in' do context 'with SAML user attempting password login' do - let!(:saml_settings) { create(:account_saml_settings, account: account) } - let!(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } + let(:saml_settings) { create(:account_saml_settings, account: account) } + let(:saml_user) { create(:user, email: 'saml@example.com', provider: 'saml', account: account) } + + before do + saml_settings + saml_user + end it 'prevents login and returns SAML authentication error' do params = { email: saml_user.email, password: 'Password1!' } @@ -16,7 +21,7 @@ RSpec.describe 'Enterprise Audit API', type: :request do expect(response).to have_http_status(:unauthorized) json_response = JSON.parse(response.body) - expect(json_response['success']).to eq(false) + expect(json_response['success']).to be(false) expect(json_response['errors']).to include(I18n.t('messages.login_saml_user')) end