diff --git a/Frontend/src/utils/dateUtils.js b/Frontend/src/utils/dateUtils.js index 920c4dbd..2e477f5c 100644 --- a/Frontend/src/utils/dateUtils.js +++ b/Frontend/src/utils/dateUtils.js @@ -1,47 +1,63 @@ /** * Unified Date Utility for HELPDESK.AI - * Fixes timezone shift issues by explicitly forcing local display. + * Compatible with Safari, Firefox, and Chrome. + * Fixes Safari ISO-8601 parsing issues by normalizing date strings. */ -export const formatTimelineDate = (dateStr) => { - if (!dateStr) return null; - - // Ensure the date string is interpreted as UTC if it's an ISO string from DB - let date; - if (typeof dateStr === 'string' && !dateStr.includes('Z') && !dateStr.includes('+')) { - // If it's a raw string without TZ, assume it was intended as UTC from our backend - date = new Date(dateStr + 'Z'); - } else { - date = new Date(dateStr); +/** + * Normalize a date string for Safari compatibility. + * Safari fails to parse "YYYY-MM-DDTHH:MM:SS" (no TZ, no 'Z'). + * This converts it to a format Safari understands. + */ +const normalizeDateString = (dateStr) => { + if (!dateStr) return null; + + // If it already has timezone info, return as-is + if (dateStr.includes('Z') || dateStr.includes('+') || dateStr.includes('T')) { + // Replace "YYYY-MM-DDTHH:MM:SS" with "YYYY-MM-DDTHH:MM:SSZ" if no TZ + if (dateStr.includes('T') && !dateStr.includes('Z') && !dateStr.includes('+')) { + return dateStr + 'Z'; } + return dateStr; + } + + // Raw date without time - assume UTC + return dateStr + 'T00:00:00Z'; +}; + +export const formatTimelineDate = (dateStr) => { + const normalized = normalizeDateString(dateStr); + if (!normalized) return null; - if (isNaN(date.getTime())) return 'Invalid Date'; - - // Using the browser's default locale and timeZone (which is the user's local) - return date.toLocaleString(undefined, { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - hour12: true - }); + const date = new Date(normalized); + if (isNaN(date.getTime())) return 'Invalid Date'; + + return date.toLocaleString(undefined, { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); }; export const getTimeZoneAbbr = () => { - try { - return new Intl.DateTimeFormat('en-US', { - timeZoneName: 'short' - }) + try { + return ( + new Intl.DateTimeFormat('en-US', { + timeZoneName: 'short', + }) .formatToParts(new Date()) - .find(part => part.type === 'timeZoneName')?.value || 'IST'; - } catch (_e) { - return 'IST'; - } + .find((part) => part.type === 'timeZoneName')?.value || 'UTC' + ); + } catch (_e) { + return 'UTC'; + } }; export const formatFullTimestamp = (dateStr) => { - const formatted = formatTimelineDate(dateStr); - if (!formatted) return 'Processing...'; - return `${formatted} (${getTimeZoneAbbr()})`; + const formatted = formatTimelineDate(dateStr); + if (!formatted) return 'Processing...'; + return `${formatted} (${getTimeZoneAbbr()})`; }; diff --git a/backend/tests/test_auto_close.py b/backend/tests/test_auto_close.py new file mode 100644 index 00000000..1573e0fa --- /dev/null +++ b/backend/tests/test_auto_close.py @@ -0,0 +1,343 @@ +""" +Unit tests for AutoCloseService. + +Tests cover: +- System settings fallback to defaults +- Ticket status update handling +- Error logging boundaries +""" + +import os +import sys +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch, call +import pytest + +# Add backend to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + +# Mock supabase before import +supabase_mock = MagicMock() +sys.modules['supabase'] = supabase_mock + +from backend.services.auto_close_service import AutoCloseService + + +class TestAutoCloseService: + """Test suite for AutoCloseService.""" + + @pytest.fixture + def mock_supabase(self): + return MagicMock() + + @pytest.fixture + def service(self): + """Create an AutoCloseService with mocked Supabase.""" + # Create a fresh supabase mock BEFORE the service constructor + fresh_supabase = MagicMock() + fresh_supabase.table.return_value = MagicMock() + fresh_supabase.table.return_value.select.return_value = MagicMock() + fresh_supabase.table.return_value.select.return_value.eq.return_value = MagicMock() + fresh_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value = MagicMock() + fresh_supabase.table.return_value.select.return_value.eq.return_value.execute.return_value.data = [] + + with patch('backend.services.auto_close_service.create_client', return_value=fresh_supabase): + svc = AutoCloseService() + svc.enabled = True + svc.default_auto_close_days = 7 + svc.supabase = fresh_supabase + return svc + + # --- Tests for get_system_settings --- + + def test_get_system_settings_returns_company_values(self, service): + """ + When system_settings exist for a company, get_system_settings should + return the stored values. + """ + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_execute = MagicMock() + mock_execute.data = {"auto_close_days": 3, "auto_close_enabled": False} + mock_single = MagicMock() + mock_single.execute.return_value = mock_execute + + mock_select.eq.return_value = mock_select + mock_select.single.return_value = mock_single + + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.get_system_settings("company-uuid-123") + + assert result["auto_close_days"] == 3 + assert result["auto_close_enabled"] is False + + def test_get_system_settings_falls_back_to_defaults_on_error(self, service): + """ + When the database query fails (exception), get_system_settings should + fall back to default values without crashing. + """ + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_single = MagicMock() + mock_single.execute.side_effect = Exception("DB connection error") + + mock_select.eq.return_value = mock_select + mock_select.single.return_value = mock_single + + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.get_system_settings("company-uuid-456") + + assert result["auto_close_days"] == service.default_auto_close_days + assert result["auto_close_enabled"] is True + + def test_get_system_settings_falls_back_when_data_missing(self, service): + """ + When the database returns empty data (company not found), settings + should fall back to defaults. + """ + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_execute = MagicMock() + mock_execute.data = None + mock_single = MagicMock() + mock_single.execute.return_value = mock_execute + + mock_select.eq.return_value = mock_select + mock_select.single.return_value = mock_single + + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.get_system_settings("nonexistent-company-uuid") + + assert result["auto_close_days"] == service.default_auto_close_days + assert result["auto_close_enabled"] is True + + def test_get_system_settings_handles_partial_data(self, service): + """ + When the database returns partial data (missing some keys), the + missing keys should fall back to defaults. + """ + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_execute = MagicMock() + mock_execute.data = {"auto_close_days": 14} + mock_single = MagicMock() + mock_single.execute.return_value = mock_execute + + mock_select.eq.return_value = mock_select + mock_select.single.return_value = mock_single + + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.get_system_settings("company-uuid-789") + + assert result["auto_close_days"] == 14 + assert result["auto_close_enabled"] is True + + # --- Tests for _close_ticket --- + + def test_close_ticket_updates_status_successfully(self, service): + """ + _close_ticket should update the ticket status to 'closed' and set + auto_closed flag, returning True on success. + """ + mock_tbl = MagicMock() + mock_update = MagicMock() + mock_eq = MagicMock() + mock_eq.execute.return_value = MagicMock() + + mock_update.eq.return_value = mock_eq + mock_tbl.update.return_value = mock_update + service.supabase.table.return_value = mock_tbl + + stats = {"closed_count": 0, "error_count": 0} + result = service._close_ticket("ticket-uuid-1", "company-uuid-1", stats) + + assert result is True + assert stats["closed_count"] == 1 + + # Verify the update call + update_args = mock_tbl.update.call_args + assert update_args is not None + update_kwargs = update_args[0][0] if update_args[0] else {} + assert update_kwargs.get("status") == "closed" + assert update_kwargs.get("auto_closed") is True + + def test_close_ticket_handles_database_error(self, service): + """ + When the database update fails, _close_ticket should catch the + exception, increment error_count, and return False. + """ + # Test the real _close_ticket behavior by wrapping it + original_close = service._close_ticket + + def mock_update(*args, **kwargs): + raise Exception("Update failed") + + mock_tbl = MagicMock() + mock_tbl.update = mock_update + service.supabase.table.return_value = mock_tbl + + with patch.object(service, '_close_ticket', wraps=original_close): + stats = {"closed_count": 0, "error_count": 0} + result = service._close_ticket("ticket-uuid-2", "company-uuid-2", stats) + + assert result is False + assert stats["closed_count"] == 0 + assert stats["error_count"] == 1 + + # --- Tests for run() --- + + def test_run_returns_disabled_when_service_disabled(self, service): + """ + When the service is disabled (enabled=False), run() should immediately + return disabled status without querying the database. + """ + service.enabled = False + + result = service.run() + + assert result == {"status": "disabled"} + service.supabase.table.assert_not_called() + + def test_run_processes_resolved_tickets(self, service): + """ + run() should fetch resolved tickets, check their age against company + settings, and close expired ones. + """ + recent_time = (datetime.now(timezone.utc) - timedelta(hours=12)).isoformat() + old_time = (datetime.now(timezone.utc) - timedelta(days=14)).isoformat() + + resolved_tickets = [ + {"id": "ticket-recent", "company_id": "company-a", "status": "resolved", "updated_at": recent_time}, + {"id": "ticket-old", "company_id": "company-a", "status": "resolved", "updated_at": old_time}, + {"id": "ticket-old-2", "company_id": "company-b", "status": "resolved", "updated_at": old_time}, + ] + + # Build mock chain: table().select().eq().execute().data + mock_execute = MagicMock() + mock_execute.data = resolved_tickets + + mock_eq = MagicMock() + mock_eq.execute.return_value = mock_execute + + mock_select = MagicMock() + mock_select.eq.return_value = mock_eq + + mock_table = MagicMock() + mock_table.select.return_value = mock_select + + # Patch the entire table method so it returns our mock + service.supabase.table = MagicMock(return_value=mock_table) + + # Use wraps so _close_ticket still increments stats + original_close = service._close_ticket + + with patch.object(service, 'get_system_settings', return_value={"auto_close_days": 7, "auto_close_enabled": True}): + with patch.object(service, '_close_ticket', wraps=original_close) as mock_close: + result = service.run() + + assert result["closed_count"] == 2 + assert result["skipped_count"] == 1 + assert mock_close.call_count == 2 + + def test_run_disabled_company_skips_tickets(self, service): + """ + When a company has auto-close disabled, all their tickets should + be skipped. + """ + old_time = (datetime.now(timezone.utc) - timedelta(days=14)).isoformat() + + resolved_tickets = [ + {"id": "ticket-1", "company_id": "company-disabled", "status": "resolved", "updated_at": old_time}, + ] + + mock_execute = MagicMock() + mock_execute.data = resolved_tickets + + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_eq = MagicMock() + mock_eq.execute.return_value = mock_execute + + mock_select.eq.return_value = mock_eq + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + with patch.object(service, 'get_system_settings', return_value={"auto_close_days": 7, "auto_close_enabled": False}): + with patch.object(service, '_close_ticket') as mock_close: + result = service.run() + + assert result["closed_count"] == 0 + assert result["skipped_count"] == 1 + mock_close.assert_not_called() + + def test_run_handles_fatal_error_gracefully(self, service): + """ + When a fatal error occurs during ticket query, run() should catch it, + increment error_count, and still return stats. + """ + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_eq = MagicMock() + mock_eq.execute.side_effect = Exception("Query failed") + + mock_select.eq.return_value = mock_eq + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.run() + + assert result["error_count"] >= 1 + assert "processed_count" in result + + # --- Tests for load/get_instance --- + + def test_load_creates_singleton(self, service): + """ + load() should create and return a singleton AutoCloseService instance. + """ + from backend.services.auto_close_service import load, get_instance + + # Reset singleton + import backend.services.auto_close_service as mod + mod._instance = None + + with patch('backend.services.auto_close_service.create_client', return_value=MagicMock()): + instance1 = load() + instance2 = load() + assert instance1 is instance2 + + def test_test_query_returns_tickets(self, service): + """ + test_query() should return a sample of resolved tickets. + """ + mock_execute = MagicMock() + mock_execute.data = [{"id": "test-1", "status": "resolved"}] + + mock_tbl = MagicMock() + mock_select = MagicMock() + mock_eq = MagicMock() + mock_limit = MagicMock() + mock_limit.execute.return_value = mock_execute + + mock_eq.limit.return_value = mock_limit + mock_select.eq.return_value = mock_eq + mock_tbl.select.return_value = mock_select + service.supabase.table.return_value = mock_tbl + + result = service.test_query() + + assert len(result) == 1 + assert result[0]["id"] == "test-1" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_duplicate_service.py b/backend/tests/test_duplicate_service.py new file mode 100644 index 00000000..4e3966c7 --- /dev/null +++ b/backend/tests/test_duplicate_service.py @@ -0,0 +1,201 @@ +""" +Unit tests for DuplicateService.check_duplicate method. + +Tests cover: +- Threshold override parameter +- Empty ticket store behavior +- Degraded mode when model is not available +""" + +import os +import sys +import json +from unittest.mock import patch, MagicMock, PropertyMock +import pytest + +# Add backend to path so we can import +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + +# Mock heavy dependencies before any import +from unittest.mock import MagicMock +sys.modules['sentence_transformers'] = MagicMock() +sys.modules['sentence_transformers.SentenceTransformer'] = MagicMock() +sys.modules['sentence_transformers.util'] = MagicMock() +sys.modules['sentence_transformers.util.cos_sim'] = MagicMock() + +from backend.services.duplicate_service import DuplicateService, SIMILARITY_THRESHOLD + + +class TestDuplicateServiceCheckDuplicate: + """Test suite for DuplicateService.check_duplicate.""" + + @pytest.fixture + def service(self): + """Create a DuplicateService instance with mocked model.""" + svc = DuplicateService() + # Mock the model as loaded with a fake embedding + mock_model = MagicMock() + mock_model.encode.return_value = MagicMock() + svc.model = mock_model + svc._loaded = True + svc._load_failed = False + svc._tickets = [] + return svc + + def test_check_duplicate_returns_no_match_when_store_empty(self, service): + """ + When the ticket store is empty and the model is available, + check_duplicate should return no duplicate. + """ + result = service.check_duplicate("New ticket about billing issue") + + assert result["is_duplicate"] is False + assert result["duplicate_ticket_id"] is None + assert result["similarity"] == 0.0 + + def test_check_duplicate_uses_custom_threshold(self, service): + """ + When a custom threshold is provided, it should be used instead of the + default SIMILARITY_THRESHOLD. + """ + + # Create a mock that returns a mock with .item() returning float + class MockCosSim: + def __init__(self, val): + self._val = val + def item(self): + return self._val + + emb1 = MagicMock() + mock_model = MagicMock() + mock_model.encode.return_value = emb1 + service.model = mock_model + service._tickets.append(("ticket-1", emb1, "Existing ticket about login error")) + + # Need to patch the module-level import inside duplicate_service + import backend.services.duplicate_service as svc_module + + with patch.object(svc_module.util, 'cos_sim', return_value=MockCosSim(0.85)): + result = service.check_duplicate("Some text", threshold=0.9) + assert result["is_duplicate"] is False + assert result["duplicate_ticket_id"] is None + + with patch.object(svc_module.util, 'cos_sim', return_value=MockCosSim(0.85)): + result = service.check_duplicate("Some text", threshold=0.8) + assert result["is_duplicate"] is True + assert result["duplicate_ticket_id"] == "ticket-1" + + def test_check_duplicate_uses_default_threshold(self, service): + """ + When no threshold is provided, the default SIMILARITY_THRESHOLD should be used. + """ + + class MockCosSim: + def __init__(self, val): + self._val = val + def item(self): + return self._val + + emb1 = MagicMock() + mock_model = MagicMock() + mock_model.encode.return_value = emb1 + service.model = mock_model + service._tickets.append(("ticket-1", emb1, "Existing ticket")) + + import backend.services.duplicate_service as svc_module + + # Default threshold is 0.70 + with patch.object(svc_module.util, 'cos_sim', return_value=MockCosSim(0.85)): + result = service.check_duplicate("Some text") + assert result["is_duplicate"] is True + + with patch.object(svc_module.util, 'cos_sim', return_value=MockCosSim(0.50)): + result = service.check_duplicate("Some text") + assert result["is_duplicate"] is False + + def test_check_duplicate_handles_degraded_mode(self, service): + """ + When the model failed to load (degraded mode), check_duplicate should + return a safe no-match result. + """ + service._loaded = False + service._load_failed = True + service.model = None + + result = service.check_duplicate("Some ticket text") + + assert result["is_duplicate"] is False + assert result["duplicate_ticket_id"] is None + assert result["similarity"] == 0.0 + + def test_check_duplicate_finds_duplicate(self, service): + """ + When a similar ticket exists, check_duplicate should identify it as duplicate. + """ + + class MockCosSim: + def __init__(self, val): + self._val = val + def item(self): + return self._val + + emb1 = MagicMock() + mock_model = MagicMock() + mock_model.encode.return_value = emb1 + service.model = mock_model + service._tickets.append(("ticket-123", emb1, "Original ticket")) + + import backend.services.duplicate_service as svc_module + + with patch.object(svc_module.util, 'cos_sim', return_value=MockCosSim(0.95)): + result = service.check_duplicate("Very similar ticket text") + assert result["is_duplicate"] is True + assert result["duplicate_ticket_id"] == "ticket-123" + assert result["similarity"] >= 0.7 + + def test_check_duplicate_returns_best_match(self, service): + """ + When multiple tickets exist, check_duplicate should return the best match. + """ + + class MockCosSim: + def __init__(self, val): + self._val = val + def item(self): + return self._val + + emb_low = MagicMock() + emb_high = MagicMock() + + mock_model = MagicMock() + mock_model.encode.return_value = MagicMock() + service.model = mock_model + service._tickets.append(("ticket-low", emb_low, "Low similarity ticket")) + service._tickets.append(("ticket-high", emb_high, "High similarity ticket")) + + # Simulate iteration: first call returns 0.3, second returns 0.85 + similarities = iter([MockCosSim(0.3), MockCosSim(0.85)]) + + import backend.services.duplicate_service as svc_module + + with patch.object(svc_module.util, 'cos_sim', side_effect=lambda a, b: next(similarities)): + result = service.check_duplicate("Some query") + assert result["is_duplicate"] is True + assert result["duplicate_ticket_id"] == "ticket-high" + assert result["similarity"] == 0.85 + + def test_check_duplicate_loads_model_if_not_loaded(self, service): + """ + check_duplicate should call load() if the model hasn't been loaded yet. + """ + service._loaded = False + service._load_failed = False + + with patch.object(service, 'load') as mock_load: + service.check_duplicate("Some text") + mock_load.assert_called_once() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])