Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27b34fbf8e | ||
|
|
01997e5c5e | ||
|
|
88deecd02e |
@@ -266,6 +266,8 @@
|
|||||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||||
|
"ALL_INBOXES": "All Inboxes",
|
||||||
|
"SEARCH_INBOX": "Search Inbox",
|
||||||
"METRICS": {
|
"METRICS": {
|
||||||
"CONVERSATIONS": {
|
"CONVERSATIONS": {
|
||||||
"NAME": "Conversations",
|
"NAME": "Conversations",
|
||||||
|
|||||||
+105
-5
@@ -9,9 +9,11 @@ import endOfDay from 'date-fns/endOfDay';
|
|||||||
import getUnixTime from 'date-fns/getUnixTime';
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
import subDays from 'date-fns/subDays';
|
import subDays from 'date-fns/subDays';
|
||||||
|
import format from 'date-fns/format';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
|
|||||||
const accountConversationHeatmap = useMapGetter(
|
const accountConversationHeatmap = useMapGetter(
|
||||||
'getAccountConversationHeatmapData'
|
'getAccountConversationHeatmapData'
|
||||||
);
|
);
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
@@ -33,19 +36,78 @@ const menuItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const selectedDays = ref(6);
|
const selectedDays = ref(6);
|
||||||
|
const selectedInbox = ref(null);
|
||||||
|
|
||||||
const selectedDayFilter = computed(() =>
|
const selectedDayFilter = computed(() =>
|
||||||
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const inboxMenuItems = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INBOX_REPORTS.ALL_INBOXES'),
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
...inboxes.value.map(inbox => ({
|
||||||
|
label: inbox.name,
|
||||||
|
value: inbox.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedInboxFilter = computed(() => {
|
||||||
|
if (!selectedInbox.value) {
|
||||||
|
return { label: t('INBOX_REPORTS.ALL_INBOXES') };
|
||||||
|
}
|
||||||
|
return inboxMenuItems.value.find(
|
||||||
|
item => item.value === selectedInbox.value.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const downloadHeatmapData = () => {
|
const downloadHeatmapData = () => {
|
||||||
const to = endOfDay(new Date());
|
const to = endOfDay(new Date());
|
||||||
store.dispatch('downloadAccountConversationHeatmap', {
|
|
||||||
daysBefore: selectedDays.value,
|
// If no inbox is selected, use the existing backend CSV endpoint
|
||||||
to: getUnixTime(to),
|
if (!selectedInbox.value) {
|
||||||
|
store.dispatch('downloadAccountConversationHeatmap', {
|
||||||
|
daysBefore: selectedDays.value,
|
||||||
|
to: getUnixTime(to),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If inbox is selected, generate CSV from store data
|
||||||
|
if (
|
||||||
|
!accountConversationHeatmap.value ||
|
||||||
|
accountConversationHeatmap.value.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV headers
|
||||||
|
const headers = ['Date', 'Hour', 'Conversations Count'];
|
||||||
|
const rows = [headers];
|
||||||
|
|
||||||
|
// Convert heatmap data to rows
|
||||||
|
accountConversationHeatmap.value.forEach(item => {
|
||||||
|
const date = new Date(item.timestamp * 1000);
|
||||||
|
const dateStr = format(date, 'yyyy-MM-dd');
|
||||||
|
const hour = date.getHours();
|
||||||
|
rows.push([dateStr, `${hour}:00 - ${hour + 1}:00`, item.value]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Convert to CSV string
|
||||||
|
const csvContent = rows.map(row => row.join(',')).join('\n');
|
||||||
|
|
||||||
|
// Generate filename with inbox name
|
||||||
|
const inboxName = selectedInbox.value.name.replace(/[^a-z0-9]/gi, '_');
|
||||||
|
const fileName = `conversation_heatmap_${inboxName}_${format(new Date(), 'dd-MM-yyyy')}.csv`;
|
||||||
|
|
||||||
|
// Download the file
|
||||||
|
downloadCsvFile(fileName, csvContent);
|
||||||
};
|
};
|
||||||
const [showDropdown, toggleDropdown] = useToggle();
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
|
||||||
const fetchHeatmapData = () => {
|
const fetchHeatmapData = () => {
|
||||||
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||||
return;
|
return;
|
||||||
@@ -54,13 +116,21 @@ const fetchHeatmapData = () => {
|
|||||||
let to = endOfDay(new Date());
|
let to = endOfDay(new Date());
|
||||||
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||||
|
|
||||||
store.dispatch('fetchAccountConversationHeatmap', {
|
const params = {
|
||||||
metric: 'conversations_count',
|
metric: 'conversations_count',
|
||||||
from: getUnixTime(from),
|
from: getUnixTime(from),
|
||||||
to: getUnixTime(to),
|
to: getUnixTime(to),
|
||||||
groupBy: 'hour',
|
groupBy: 'hour',
|
||||||
businessHours: false,
|
businessHours: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Add inbox filtering if an inbox is selected
|
||||||
|
if (selectedInbox.value) {
|
||||||
|
params.type = 'inbox';
|
||||||
|
params.id = selectedInbox.value.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch('fetchAccountConversationHeatmap', params);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAction = ({ value }) => {
|
const handleAction = ({ value }) => {
|
||||||
@@ -69,9 +139,18 @@ const handleAction = ({ value }) => {
|
|||||||
fetchHeatmapData();
|
fetchHeatmapData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInboxAction = ({ value }) => {
|
||||||
|
toggleInboxDropdown(false);
|
||||||
|
selectedInbox.value = value
|
||||||
|
? inboxes.value.find(inbox => inbox.id === value)
|
||||||
|
: null;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
store.dispatch('inboxes/get');
|
||||||
fetchHeatmapData();
|
fetchHeatmapData();
|
||||||
startRefetching();
|
startRefetching();
|
||||||
});
|
});
|
||||||
@@ -100,6 +179,27 @@ onMounted(() => {
|
|||||||
@action="handleAction($event)"
|
@action="handleAction($event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleInboxDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedInboxFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2 max-w-[200px]"
|
||||||
|
@click="toggleInboxDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showInboxDropdown"
|
||||||
|
:menu-items="inboxMenuItems"
|
||||||
|
show-search
|
||||||
|
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
|
||||||
|
@action="handleInboxAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
sm
|
sm
|
||||||
slate
|
slate
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Provides session management functionality for Devise Token Auth tokens.
|
||||||
|
# Handles logout operations, session limits, and token cleanup.
|
||||||
|
module SessionManageable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
# Clears all active sessions for the user.
|
||||||
|
# @return [void]
|
||||||
|
def logout_all_sessions!
|
||||||
|
self.tokens = {}
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
# Logs out a specific session by client ID.
|
||||||
|
# @param client_id [String] the client identifier to logout
|
||||||
|
# @return [Boolean] true if session was found and removed
|
||||||
|
def logout_session!(client_id)
|
||||||
|
return false unless client_id.present? && tokens.present?
|
||||||
|
|
||||||
|
removed = tokens.delete(client_id)
|
||||||
|
save! if removed
|
||||||
|
|
||||||
|
removed.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Removes tokens that expired before the given timestamp.
|
||||||
|
# @param timestamp [Time, Integer] cutoff time for token cleanup
|
||||||
|
# @return [void]
|
||||||
|
def reset_tokens_before!(timestamp)
|
||||||
|
return unless tokens.present?
|
||||||
|
|
||||||
|
self.tokens = tokens.select do |_client_id, token_data|
|
||||||
|
(token_data['expiry'] || 0) >= timestamp.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
save!
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns count of non-expired active sessions.
|
||||||
|
# @return [Integer] number of active sessions
|
||||||
|
def active_session_count
|
||||||
|
return 0 unless tokens.present?
|
||||||
|
|
||||||
|
current_time = Time.current.to_i
|
||||||
|
tokens.count { |_client_id, token_data| (token_data['expiry'] || 0) > current_time }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checks if user has exceeded configured session limit.
|
||||||
|
# @return [Boolean] true if session limit is exceeded
|
||||||
|
def session_limit_exceeded?
|
||||||
|
active_session_count >= session_limit
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns session information for all active tokens.
|
||||||
|
# @return [Array<Hash>] array of session info, sorted by expiry (newest first)
|
||||||
|
def session_info
|
||||||
|
return [] unless tokens.present?
|
||||||
|
|
||||||
|
tokens.map do |client_id, token_data|
|
||||||
|
{
|
||||||
|
client_id: client_id,
|
||||||
|
expiry: Time.zone.at(token_data['expiry'] || 0)
|
||||||
|
}
|
||||||
|
end.sort_by { |session| session[:expiry] }.reverse
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Returns configured session limit from GlobalConfig.
|
||||||
|
# Defaults to infinity if not configured.
|
||||||
|
# @return [Integer, Float] session limit or Float::INFINITY
|
||||||
|
def session_limit
|
||||||
|
@session_limit ||= GlobalConfig.get(
|
||||||
|
'USER_SESSION_LIMIT',
|
||||||
|
'USER_SESSION_LIMIT_PER_USER',
|
||||||
|
account: Current.account
|
||||||
|
)&.to_i || Float::INFINITY
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -47,6 +47,7 @@ class User < ApplicationRecord
|
|||||||
include Pubsubable
|
include Pubsubable
|
||||||
include Rails.application.routes.url_helpers
|
include Rails.application.routes.url_helpers
|
||||||
include Reportable
|
include Reportable
|
||||||
|
include SessionManageable
|
||||||
include SsoAuthenticatable
|
include SsoAuthenticatable
|
||||||
include UserAttributeHelpers
|
include UserAttributeHelpers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe SessionManageable do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:current_time) { Time.current.to_i }
|
||||||
|
let(:expired_time) { 1.hour.ago.to_i }
|
||||||
|
let(:future_time) { 1.hour.from_now.to_i }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock GlobalConfig for session limit
|
||||||
|
allow(GlobalConfig).to receive(:get).with(
|
||||||
|
'USER_SESSION_LIMIT',
|
||||||
|
'USER_SESSION_LIMIT_PER_USER',
|
||||||
|
account: nil
|
||||||
|
).and_return('5')
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#logout_all_sessions!' do
|
||||||
|
it 'clears all tokens and saves the user' do
|
||||||
|
user.tokens = {
|
||||||
|
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||||
|
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.logout_all_sessions!
|
||||||
|
|
||||||
|
expect(user.tokens).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'works with empty tokens' do
|
||||||
|
user.tokens = {}
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.logout_all_sessions!
|
||||||
|
expect(user.tokens).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'works with nil tokens' do
|
||||||
|
user.tokens = nil
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.logout_all_sessions!
|
||||||
|
expect(user.tokens).to eq({})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#logout_session!' do
|
||||||
|
before do
|
||||||
|
user.tokens = {
|
||||||
|
'client1' => { 'token' => 'hashed_token_1', 'expiry' => future_time },
|
||||||
|
'client2' => { 'token' => 'hashed_token_2', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes specific client token and saves' do
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
result = user.logout_session!('client1')
|
||||||
|
|
||||||
|
expect(result).to be true
|
||||||
|
expect(user.tokens).not_to have_key('client1')
|
||||||
|
expect(user.tokens).to have_key('client2')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for non-existent client' do
|
||||||
|
result = user.logout_session!('non_existent_client')
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for empty client_id' do
|
||||||
|
result = user.logout_session!('')
|
||||||
|
expect(result).to be false
|
||||||
|
|
||||||
|
result = user.logout_session!(nil)
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when no tokens present' do
|
||||||
|
user.tokens = nil
|
||||||
|
result = user.logout_session!('client1')
|
||||||
|
expect(result).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#reset_tokens_before!' do
|
||||||
|
before do
|
||||||
|
user.tokens = {
|
||||||
|
'expired_client' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||||
|
'current_client' => { 'token' => 'token2', 'expiry' => future_time },
|
||||||
|
'edge_case_client' => { 'token' => 'token3', 'expiry' => current_time }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes tokens that expired before the given timestamp' do
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.reset_tokens_before!(current_time)
|
||||||
|
|
||||||
|
expect(user.tokens).not_to have_key('expired_client')
|
||||||
|
expect(user.tokens).to have_key('current_client')
|
||||||
|
expect(user.tokens).to have_key('edge_case_client')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles timestamp as Time object' do
|
||||||
|
timestamp = Time.zone.at(current_time)
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.reset_tokens_before!(timestamp)
|
||||||
|
|
||||||
|
expect(user.tokens).not_to have_key('expired_client')
|
||||||
|
expect(user.tokens).to have_key('current_client')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does nothing when no tokens present' do
|
||||||
|
user.tokens = nil
|
||||||
|
user.reset_tokens_before!(current_time)
|
||||||
|
# tokens gets initialized to empty hash during the process
|
||||||
|
expect(user.tokens).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles tokens with missing expiry' do
|
||||||
|
user.tokens = {
|
||||||
|
'no_expiry_client' => { 'token' => 'token1' },
|
||||||
|
'zero_expiry_client' => { 'token' => 'token2', 'expiry' => 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user).to receive(:save!)
|
||||||
|
user.reset_tokens_before!(current_time)
|
||||||
|
|
||||||
|
expect(user.tokens).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#active_session_count' do
|
||||||
|
it 'counts only non-expired tokens' do
|
||||||
|
user.tokens = {
|
||||||
|
'expired1' => { 'token' => 'token1', 'expiry' => expired_time },
|
||||||
|
'active1' => { 'token' => 'token2', 'expiry' => future_time },
|
||||||
|
'active2' => { 'token' => 'token3', 'expiry' => future_time },
|
||||||
|
'expired2' => { 'token' => 'token4', 'expiry' => expired_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user.active_session_count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 0 when no tokens present' do
|
||||||
|
user.tokens = nil
|
||||||
|
expect(user.active_session_count).to eq(0)
|
||||||
|
|
||||||
|
user.tokens = {}
|
||||||
|
expect(user.active_session_count).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles tokens with missing expiry' do
|
||||||
|
user.tokens = {
|
||||||
|
'no_expiry' => { 'token' => 'token1' },
|
||||||
|
'active' => { 'token' => 'token2', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user.active_session_count).to eq(1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#session_limit_exceeded?' do
|
||||||
|
it 'returns true when active sessions exceed limit' do
|
||||||
|
# Mock 3 session limit
|
||||||
|
allow(GlobalConfig).to receive(:get).and_return('3')
|
||||||
|
|
||||||
|
user.tokens = {
|
||||||
|
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||||
|
'active2' => { 'token' => 'token2', 'expiry' => future_time },
|
||||||
|
'active3' => { 'token' => 'token3', 'expiry' => future_time },
|
||||||
|
'active4' => { 'token' => 'token4', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user.session_limit_exceeded?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when within limit' do
|
||||||
|
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||||
|
|
||||||
|
user.tokens = {
|
||||||
|
'active1' => { 'token' => 'token1', 'expiry' => future_time },
|
||||||
|
'active2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(user.session_limit_exceeded?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles infinite limit' do
|
||||||
|
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||||
|
|
||||||
|
user.tokens = {}
|
||||||
|
(1..100).each do |i|
|
||||||
|
user.tokens["client#{i}"] = { 'token' => "token#{i}", 'expiry' => future_time }
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(user.session_limit_exceeded?).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#session_info' do
|
||||||
|
it 'returns session information sorted by expiry (newest first)' do
|
||||||
|
earlier_time = 2.hours.from_now.to_i
|
||||||
|
later_time = 3.hours.from_now.to_i
|
||||||
|
|
||||||
|
user.tokens = {
|
||||||
|
'client1' => { 'token' => 'token1', 'expiry' => earlier_time },
|
||||||
|
'client2' => { 'token' => 'token2', 'expiry' => later_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = user.session_info
|
||||||
|
|
||||||
|
expect(sessions.size).to eq(2)
|
||||||
|
expect(sessions[0][:client_id]).to eq('client2')
|
||||||
|
expect(sessions[0][:expiry]).to eq(Time.zone.at(later_time))
|
||||||
|
expect(sessions[1][:client_id]).to eq('client1')
|
||||||
|
expect(sessions[1][:expiry]).to eq(Time.zone.at(earlier_time))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty array when no tokens' do
|
||||||
|
user.tokens = nil
|
||||||
|
expect(user.session_info).to eq([])
|
||||||
|
|
||||||
|
user.tokens = {}
|
||||||
|
expect(user.session_info).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles tokens with missing expiry' do
|
||||||
|
user.tokens = {
|
||||||
|
'client1' => { 'token' => 'token1' },
|
||||||
|
'client2' => { 'token' => 'token2', 'expiry' => future_time }
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = user.session_info
|
||||||
|
|
||||||
|
expect(sessions.size).to eq(2)
|
||||||
|
expect(sessions[0][:expiry]).to eq(Time.zone.at(future_time))
|
||||||
|
expect(sessions[1][:expiry]).to eq(Time.zone.at(0))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'private methods' do
|
||||||
|
describe '#session_limit' do
|
||||||
|
it 'returns configured limit from GlobalConfig' do
|
||||||
|
allow(GlobalConfig).to receive(:get).with(
|
||||||
|
'USER_SESSION_LIMIT',
|
||||||
|
'USER_SESSION_LIMIT_PER_USER',
|
||||||
|
account: nil
|
||||||
|
).and_return('10')
|
||||||
|
|
||||||
|
limit = user.send(:session_limit)
|
||||||
|
expect(limit).to eq(10)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns infinity when no limit configured' do
|
||||||
|
allow(GlobalConfig).to receive(:get).and_return(nil)
|
||||||
|
|
||||||
|
limit = user.send(:session_limit)
|
||||||
|
expect(limit).to eq(Float::INFINITY)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'memoizes the result' do
|
||||||
|
allow(GlobalConfig).to receive(:get).and_return('5')
|
||||||
|
|
||||||
|
# First call
|
||||||
|
limit1 = user.send(:session_limit)
|
||||||
|
# Second call should use memoized value
|
||||||
|
limit2 = user.send(:session_limit)
|
||||||
|
|
||||||
|
expect(limit1).to eq(limit2)
|
||||||
|
expect(GlobalConfig).to have_received(:get).once
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user