""" Tests for BQAS Notifier Module Tests for the local notification system that replaces GitHub Actions notifications. """ import json import os import sys import tempfile from datetime import datetime from pathlib import Path from unittest.mock import patch, MagicMock import subprocess import pytest # Import notifier directly to avoid __init__.py dependency issues import importlib.util spec = importlib.util.spec_from_file_location( "notifier", Path(__file__).parent.parent.parent / "bqas" / "notifier.py" ) notifier_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(notifier_module) BQASNotifier = notifier_module.BQASNotifier Notification = notifier_module.Notification NotificationConfig = notifier_module.NotificationConfig class TestNotificationConfig: """Tests for NotificationConfig dataclass.""" def test_default_config(self): """Test default configuration values.""" config = NotificationConfig() assert config.enabled is True assert config.desktop_enabled is True assert config.slack_enabled is False assert config.email_enabled is False assert config.log_file == "/var/log/bqas/notifications.log" def test_config_from_env(self): """Test configuration from environment variables.""" with patch.dict(os.environ, { "BQAS_NOTIFY_ENABLED": "true", "BQAS_NOTIFY_DESKTOP": "false", "BQAS_NOTIFY_SLACK": "true", "BQAS_SLACK_WEBHOOK": "https://hooks.slack.com/test", "BQAS_SLACK_CHANNEL": "#test-channel", }): config = NotificationConfig.from_env() assert config.enabled is True assert config.desktop_enabled is False assert config.slack_enabled is True assert config.slack_webhook_url == "https://hooks.slack.com/test" assert config.slack_channel == "#test-channel" def test_config_disabled(self): """Test disabled notification configuration.""" with patch.dict(os.environ, {"BQAS_NOTIFY_ENABLED": "false"}): config = NotificationConfig.from_env() assert config.enabled is False class TestNotification: """Tests for Notification dataclass.""" def test_notification_creation(self): """Test creating a notification.""" notification = Notification( status="success", message="All tests passed", details="Golden: 97/97, RAG: 26/26", ) assert notification.status == "success" assert notification.message == "All tests passed" assert notification.details == "Golden: 97/97, RAG: 26/26" assert notification.source == "bqas" assert notification.timestamp # Should be auto-generated def test_notification_timestamp_auto(self): """Test that timestamp is auto-generated.""" notification = Notification(status="failure", message="Test") # Timestamp should be in ISO format datetime.fromisoformat(notification.timestamp) def test_notification_statuses(self): """Test different notification statuses.""" for status in ["success", "failure", "warning"]: notification = Notification(status=status, message="Test") assert notification.status == status class TestBQASNotifier: """Tests for BQASNotifier class.""" def test_notifier_creation(self): """Test creating a notifier instance.""" notifier = BQASNotifier() assert notifier.config is not None def test_notifier_with_config(self): """Test creating notifier with custom config.""" config = NotificationConfig( desktop_enabled=False, slack_enabled=True, slack_webhook_url="https://test.webhook", ) notifier = BQASNotifier(config=config) assert notifier.config.desktop_enabled is False assert notifier.config.slack_enabled is True def test_notify_disabled(self): """Test that notify returns False when disabled.""" config = NotificationConfig(enabled=False) notifier = BQASNotifier(config=config) notification = Notification(status="success", message="Test") result = notifier.notify(notification) assert result is False def test_log_notification(self): """Test logging notifications to file.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f: log_path = f.name try: config = NotificationConfig( enabled=True, desktop_enabled=False, log_file=log_path, ) notifier = BQASNotifier(config=config) notification = Notification( status="success", message="Test message", details="Test details", ) notifier._log_notification(notification) # Check log file contents with open(log_path) as f: log_content = f.read() log_entry = json.loads(log_content.strip()) assert log_entry["status"] == "success" assert log_entry["message"] == "Test message" assert log_entry["details"] == "Test details" assert "logged_at" in log_entry finally: os.unlink(log_path) @patch("subprocess.run") def test_send_desktop_success(self, mock_run): """Test sending desktop notification.""" mock_run.return_value = MagicMock(returncode=0) config = NotificationConfig(desktop_enabled=True) notifier = BQASNotifier(config=config) notification = Notification(status="success", message="Test") result = notifier._send_desktop(notification) assert result is True mock_run.assert_called_once() # Check osascript was called call_args = mock_run.call_args assert call_args[0][0][0] == "osascript" @patch("subprocess.run") def test_send_desktop_failure_sound(self, mock_run): """Test that failure notifications use different sound.""" mock_run.return_value = MagicMock(returncode=0) config = NotificationConfig( desktop_enabled=True, desktop_sound_failure="Basso", ) notifier = BQASNotifier(config=config) notification = Notification(status="failure", message="Test failed") notifier._send_desktop(notification) # Check that Basso sound was used call_args = mock_run.call_args[0][0] assert "Basso" in call_args[2] @patch("urllib.request.urlopen") def test_send_slack(self, mock_urlopen): """Test sending Slack notification.""" mock_response = MagicMock() mock_response.status = 200 mock_urlopen.return_value.__enter__.return_value = mock_response config = NotificationConfig( slack_enabled=True, slack_webhook_url="https://hooks.slack.com/test", slack_channel="#test", ) notifier = BQASNotifier(config=config) notification = Notification( status="failure", message="Tests failed", details="INT-005, INT-012", ) result = notifier._send_slack(notification) assert result is True mock_urlopen.assert_called_once() def test_get_title(self): """Test title generation based on status.""" assert BQASNotifier._get_title("success") == "BQAS Erfolgreich" assert BQASNotifier._get_title("failure") == "BQAS Fehlgeschlagen" assert BQASNotifier._get_title("warning") == "BQAS Warnung" assert BQASNotifier._get_title("unknown") == "BQAS" def test_get_emoji(self): """Test emoji generation for Slack.""" assert BQASNotifier._get_emoji("success") == ":white_check_mark:" assert BQASNotifier._get_emoji("failure") == ":x:" assert BQASNotifier._get_emoji("warning") == ":warning:" def test_get_color(self): """Test color generation for Slack attachments.""" assert BQASNotifier._get_color("success") == "good" assert BQASNotifier._get_color("failure") == "danger" assert BQASNotifier._get_color("warning") == "warning" class TestNotifierIntegration: """Integration tests for the notifier system.""" def test_full_notification_flow(self): """Test complete notification flow with logging only.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f: log_path = f.name try: config = NotificationConfig( enabled=True, desktop_enabled=False, # Disable for CI slack_enabled=False, email_enabled=False, log_file=log_path, ) notifier = BQASNotifier(config=config) # Success notification success_notif = Notification( status="success", message="All BQAS tests passed", details="Golden: 97/97, RAG: 26/26, Synthetic: 50/50", ) result = notifier.notify(success_notif) assert result is True # Failure notification failure_notif = Notification( status="failure", message="3 tests failed", details="INT-005, INT-012, RAG-003", ) result = notifier.notify(failure_notif) assert result is True # Check both notifications were logged with open(log_path) as f: lines = f.readlines() assert len(lines) == 2 first = json.loads(lines[0]) assert first["status"] == "success" second = json.loads(lines[1]) assert second["status"] == "failure" finally: os.unlink(log_path) def test_notification_with_special_characters(self): """Test notifications with special characters in message.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f: log_path = f.name try: config = NotificationConfig( enabled=True, desktop_enabled=False, log_file=log_path, ) notifier = BQASNotifier(config=config) notification = Notification( status="warning", message='Test mit "Anführungszeichen" und Umlauten: äöü', details="Spezielle Zeichen: <>&'", ) result = notifier.notify(notification) assert result is True # Verify logged correctly with open(log_path) as f: log_entry = json.loads(f.read().strip()) assert "Anführungszeichen" in log_entry["message"] assert "äöü" in log_entry["message"] finally: os.unlink(log_path) class TestSchedulerScripts: """Tests for scheduler shell scripts.""" def test_run_bqas_script_exists(self): """Test that run_bqas.sh exists and is executable.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "run_bqas.sh" assert script_path.exists(), f"Script not found: {script_path}" # Check executable assert os.access(script_path, os.X_OK), "Script is not executable" def test_run_bqas_script_syntax(self): """Test run_bqas.sh has valid bash syntax.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "run_bqas.sh" result = subprocess.run( ["bash", "-n", str(script_path)], capture_output=True, text=True, ) assert result.returncode == 0, f"Syntax error: {result.stderr}" def test_install_script_exists(self): """Test that install_bqas_scheduler.sh exists.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "install_bqas_scheduler.sh" assert script_path.exists(), f"Script not found: {script_path}" assert os.access(script_path, os.X_OK), "Script is not executable" def test_install_script_syntax(self): """Test install_bqas_scheduler.sh has valid bash syntax.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "install_bqas_scheduler.sh" result = subprocess.run( ["bash", "-n", str(script_path)], capture_output=True, text=True, ) assert result.returncode == 0, f"Syntax error: {result.stderr}" def test_plist_file_exists(self): """Test that launchd plist template exists.""" plist_path = Path(__file__).parent.parent.parent / "scripts" / "com.breakpilot.bqas.plist" assert plist_path.exists(), f"Plist not found: {plist_path}" @pytest.mark.skipif(sys.platform != "darwin", reason="plutil only available on macOS") def test_plist_valid_xml(self): """Test that plist is valid XML.""" plist_path = Path(__file__).parent.parent.parent / "scripts" / "com.breakpilot.bqas.plist" result = subprocess.run( ["plutil", "-lint", str(plist_path)], capture_output=True, text=True, ) assert result.returncode == 0, f"Invalid plist: {result.stderr}" def test_git_hook_exists(self): """Test that git hook template exists.""" hook_path = Path(__file__).parent.parent.parent / "scripts" / "post-commit.hook" assert hook_path.exists(), f"Hook not found: {hook_path}" def test_run_bqas_help(self): """Test run_bqas.sh --help flag.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "run_bqas.sh" result = subprocess.run( [str(script_path), "--help"], capture_output=True, text=True, ) assert result.returncode == 0 assert "Usage" in result.stdout assert "--quick" in result.stdout assert "--golden" in result.stdout def test_install_script_status(self): """Test install_bqas_scheduler.sh status command.""" script_path = Path(__file__).parent.parent.parent / "scripts" / "install_bqas_scheduler.sh" result = subprocess.run( [str(script_path), "status"], capture_output=True, text=True, ) # Status should always work (even if not installed) assert result.returncode == 0 assert "BQAS Scheduler Status" in result.stdout