- Fixes SLA breach computation to respect the "Only during business hours" setting - Backend now pre-computes SLA deadlines, simplifying frontend logic ## How it works Before: SLA deadlines were calculated using wall-clock time, ignoring business hours. After: When an SLA policy has "Only during business hours" enabled and the inbox has working hours configured, the deadline is calculated by adding threshold time only during business hours. **How you check if a conversation has a SLA hit or miss?** <img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM" src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d" /> **Example:** - Conversation created: Friday 4:30 PM - FRT threshold: 1 hour - Business hours: Mon-Fri 9 AM - 5 PM | | Breach time | |--|--| | Before | Friday 5:30 PM | | After | Monday 9:30 AM | ## Test plan - [x] Create an SLA policy with "Only during business hours" enabled - [x] Configure inbox with business hours (e.g., Mon-Fri 9-5) - [x] Conversation created during business hours - Create a conversation on Wednesday 10:00 AM UTC - Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours later) - [x] Conversation created before business hours - Create a conversation on Wednesday 7:00 AM UTC - Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts at 9 AM) - [x] Conversation created after business hours - Create a conversation on Wednesday 6:00 PM UTC - Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts next day 9 AM) - [x] Conversation created on weekend - Create a conversation on Saturday 10:00 AM UTC - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend) - [x] Threshold spans weekend - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h Monday) - [x] SLA without business hours - Create an SLA policy with only_during_business_hours: false - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time) - [x] All Day marked as closed_all_day - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT - Expected: FRT deadline shows Thursday 10:00 AM UTC - [x] All Day marked as open_all_day - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT - Expected: FRT deadline shows Saturday 12:00 PM UTC - [x] UI displays correct countdown - Verify conversation card shows correct SLA timer - Verify timer shows flame icon when breached - Verify timer shows alarm icon when within threshold - Time updates automatically when time passes - [x] Verify the breach with a different timezone than your local timezone --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
import { evaluateSLAStatus } from '../slaHelper';
|
|
|
|
describe('#SLA Helpers', () => {
|
|
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(currentTimestamp * 1000);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('evaluateSLAStatus', () => {
|
|
describe('when inputs are invalid', () => {
|
|
it('returns empty status when appliedSla is null', () => {
|
|
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
|
|
expect(result).toEqual({
|
|
type: '',
|
|
threshold: '',
|
|
icon: '',
|
|
isSlaMissed: false,
|
|
});
|
|
});
|
|
|
|
it('returns empty status when chat is null', () => {
|
|
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
|
|
expect(result).toEqual({
|
|
type: '',
|
|
threshold: '',
|
|
icon: '',
|
|
isSlaMissed: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('FRT (First Response Time)', () => {
|
|
it('returns FRT status when first reply not made and within threshold', () => {
|
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
|
|
const chat = { first_reply_created_at: null, status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('1h');
|
|
expect(result.icon).toBe('alarm');
|
|
expect(result.isSlaMissed).toBe(false);
|
|
});
|
|
|
|
it('returns missed FRT status when threshold is exceeded', () => {
|
|
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
|
|
const chat = { first_reply_created_at: null, status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('30m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('does not return FRT when first reply was made before due time', () => {
|
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 1000,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).not.toBe('FRT');
|
|
});
|
|
|
|
it('does not return FRT when first reply was made at due time', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp - 600,
|
|
sla_rt_due_at: currentTimestamp + 1800,
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 600,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('RT');
|
|
});
|
|
|
|
it('returns missed FRT when first reply was made after due time', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp - 600,
|
|
sla_rt_due_at: currentTimestamp + 1800,
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 300,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('10m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp - 600,
|
|
sla_rt_due_at: currentTimestamp + 1800,
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: new Date(
|
|
(currentTimestamp - 300) * 1000
|
|
).toISOString(),
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('10m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('uses the due time for a missed FRT event created after the deadline', () => {
|
|
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
|
|
const chat = { first_reply_created_at: null, status: 'open' };
|
|
const slaEvents = [
|
|
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
|
|
];
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('1h');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('NRT (Next Response Time)', () => {
|
|
it('returns NRT status when waiting for response and within threshold', () => {
|
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: currentTimestamp - 600,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('NRT');
|
|
expect(result.threshold).toBe('30m');
|
|
expect(result.icon).toBe('alarm');
|
|
expect(result.isSlaMissed).toBe(false);
|
|
});
|
|
|
|
it('returns missed NRT status when threshold is exceeded', () => {
|
|
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: currentTimestamp - 2700,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('NRT');
|
|
expect(result.threshold).toBe('15m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('does not return NRT when not waiting for response', () => {
|
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: null,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).not.toBe('NRT');
|
|
});
|
|
|
|
it('returns missed NRT when a recorded NRT miss exists after response', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: null,
|
|
status: 'open',
|
|
};
|
|
const slaEvents = [
|
|
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
|
|
];
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
|
|
|
expect(result.type).toBe('NRT');
|
|
expect(result.threshold).toBe('15m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
|
|
const appliedSla = {
|
|
sla_nrt_due_at: currentTimestamp + 300,
|
|
sla_rt_due_at: currentTimestamp + 1800,
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: currentTimestamp - 60,
|
|
status: 'open',
|
|
};
|
|
const slaEvents = [
|
|
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
|
|
];
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
|
|
|
expect(result.type).toBe('NRT');
|
|
expect(result.threshold).toBe('30m');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('does not return NRT when first reply not made', () => {
|
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
|
const chat = {
|
|
first_reply_created_at: null,
|
|
waiting_since: currentTimestamp - 600,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).not.toBe('NRT');
|
|
});
|
|
});
|
|
|
|
describe('RT (Resolution Time)', () => {
|
|
it('returns RT status when conversation is open and within threshold', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 3600,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('RT');
|
|
expect(result.threshold).toBe('2h');
|
|
expect(result.icon).toBe('alarm');
|
|
expect(result.isSlaMissed).toBe(false);
|
|
});
|
|
|
|
it.each(['pending', 'snoozed'])(
|
|
'returns RT status when conversation is %s and within threshold',
|
|
status => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 3600,
|
|
status,
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('RT');
|
|
expect(result.threshold).toBe('2h');
|
|
expect(result.icon).toBe('alarm');
|
|
expect(result.isSlaMissed).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('returns missed RT status when threshold is exceeded', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('RT');
|
|
expect(result.threshold).toBe('1h');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('uses the due time for a missed RT event created after the deadline', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
status: 'open',
|
|
};
|
|
const slaEvents = [
|
|
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
|
|
];
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
|
|
|
expect(result.type).toBe('RT');
|
|
expect(result.threshold).toBe('1h');
|
|
expect(result.icon).toBe('flame');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('does not return RT when conversation is resolved', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 3600,
|
|
status: 'resolved',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('priority selection', () => {
|
|
it('returns most urgent SLA when multiple are active', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
|
|
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
|
|
sla_rt_due_at: currentTimestamp + 3600, // 1h
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: null,
|
|
waiting_since: currentTimestamp - 600,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
// FRT is selected because first_reply_created_at is null
|
|
// NRT is not checked when first_reply_created_at is null
|
|
expect(result.type).toBe('RT');
|
|
expect(result.threshold).toBe('1h');
|
|
});
|
|
|
|
it('returns most urgent missed SLA over upcoming SLA', () => {
|
|
const appliedSla = {
|
|
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
|
|
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 7200,
|
|
waiting_since: currentTimestamp - 2100,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('NRT');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
|
|
it('returns an existing missed SLA over a closer upcoming SLA', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
|
|
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: null,
|
|
status: 'open',
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.type).toBe('FRT');
|
|
expect(result.threshold).toBe('2h');
|
|
expect(result.isSlaMissed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('time formatting', () => {
|
|
it('formats time in days and hours', () => {
|
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
|
const chat = { status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.threshold).toBe('1d 1h');
|
|
});
|
|
|
|
it('formats time less than a minute as 1m', () => {
|
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
|
|
const chat = { first_reply_created_at: null, status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.threshold).toBe('1m');
|
|
});
|
|
|
|
it('formats months correctly', () => {
|
|
const appliedSla = {
|
|
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
|
|
}; // 1 month + 1 day
|
|
const chat = { status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result.threshold).toBe('1mo 1d');
|
|
});
|
|
});
|
|
|
|
describe('empty status scenarios', () => {
|
|
it('returns empty when no SLA thresholds are set', () => {
|
|
const appliedSla = {};
|
|
const chat = { status: 'open' };
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result).toEqual({
|
|
type: '',
|
|
threshold: '',
|
|
icon: '',
|
|
isSlaMissed: false,
|
|
});
|
|
});
|
|
|
|
it('returns empty when all conditions are met', () => {
|
|
const appliedSla = {
|
|
sla_frt_due_at: currentTimestamp + 3600,
|
|
sla_nrt_due_at: currentTimestamp + 1800,
|
|
sla_rt_due_at: currentTimestamp + 7200,
|
|
};
|
|
const chat = {
|
|
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
|
|
waiting_since: null, // Not waiting, so NRT not applicable
|
|
status: 'resolved', // RT not applicable
|
|
};
|
|
|
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
|
|
|
expect(result).toEqual({
|
|
type: '',
|
|
threshold: '',
|
|
icon: '',
|
|
isSlaMissed: false,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|