A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
14 KiB
Python
408 lines
14 KiB
Python
"""
|
|
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
|