Implement dashboard-controlled night mode for automatic Docker service management. Services are stopped at a configurable time (default 22:00) and restarted in the morning (default 06:00). Features: - Python/FastAPI scheduler service (port 8096) - Admin dashboard API routes at /api/admin/night-mode - Toggle for enable/disable night mode - Time picker for shutdown and startup times - Manual start/stop buttons for immediate actions - Excluded services (night-scheduler, nginx always run) Files added: - night-scheduler/scheduler.py - Main scheduler with REST API - night-scheduler/Dockerfile - Container with Docker CLI - night-scheduler/requirements.txt - FastAPI, Uvicorn, Pydantic - night-scheduler/tests/test_scheduler.py - Unit tests - admin-v2/app/api/admin/night-mode/* - API proxy routes - .claude/rules/night-scheduler.md - Developer documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""
|
|
Tests für den Night Scheduler
|
|
|
|
Unit Tests für:
|
|
- Konfiguration laden/speichern
|
|
- Zeit-Parsing
|
|
- Nächste Aktion berechnen
|
|
- API Endpoints
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from datetime import datetime, time, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
from fastapi.testclient import TestClient
|
|
|
|
# Importiere die zu testenden Funktionen
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from scheduler import (
|
|
app,
|
|
NightModeConfig,
|
|
NightModeStatus,
|
|
parse_time,
|
|
calculate_next_action,
|
|
format_timedelta,
|
|
load_config,
|
|
save_config,
|
|
CONFIG_PATH,
|
|
)
|
|
|
|
|
|
# Test Client für FastAPI
|
|
client = TestClient(app)
|
|
|
|
|
|
class TestParseTime:
|
|
"""Tests für die parse_time Funktion"""
|
|
|
|
def test_parse_time_valid_morning(self):
|
|
"""Gültige Morgenzeit parsen"""
|
|
result = parse_time("06:00")
|
|
assert result.hour == 6
|
|
assert result.minute == 0
|
|
|
|
def test_parse_time_valid_evening(self):
|
|
"""Gültige Abendzeit parsen"""
|
|
result = parse_time("22:30")
|
|
assert result.hour == 22
|
|
assert result.minute == 30
|
|
|
|
def test_parse_time_midnight(self):
|
|
"""Mitternacht parsen"""
|
|
result = parse_time("00:00")
|
|
assert result.hour == 0
|
|
assert result.minute == 0
|
|
|
|
def test_parse_time_end_of_day(self):
|
|
"""23:59 parsen"""
|
|
result = parse_time("23:59")
|
|
assert result.hour == 23
|
|
assert result.minute == 59
|
|
|
|
|
|
class TestFormatTimedelta:
|
|
"""Tests für die format_timedelta Funktion"""
|
|
|
|
def test_format_hours_and_minutes(self):
|
|
"""Stunden und Minuten formatieren"""
|
|
td = timedelta(hours=4, minutes=23)
|
|
result = format_timedelta(td)
|
|
assert result == "4h 23min"
|
|
|
|
def test_format_only_minutes(self):
|
|
"""Nur Minuten formatieren"""
|
|
td = timedelta(minutes=45)
|
|
result = format_timedelta(td)
|
|
assert result == "45min"
|
|
|
|
def test_format_zero(self):
|
|
"""Null formatieren"""
|
|
td = timedelta(minutes=0)
|
|
result = format_timedelta(td)
|
|
assert result == "0min"
|
|
|
|
def test_format_many_hours(self):
|
|
"""Viele Stunden formatieren"""
|
|
td = timedelta(hours=15, minutes=30)
|
|
result = format_timedelta(td)
|
|
assert result == "15h 30min"
|
|
|
|
|
|
class TestCalculateNextAction:
|
|
"""Tests für die calculate_next_action Funktion"""
|
|
|
|
def test_disabled_returns_none(self):
|
|
"""Deaktivierter Modus gibt None zurück"""
|
|
config = NightModeConfig(enabled=False)
|
|
action, next_time, time_until = calculate_next_action(config)
|
|
assert action is None
|
|
assert next_time is None
|
|
assert time_until is None
|
|
|
|
def test_before_shutdown_time(self):
|
|
"""Vor Shutdown-Zeit: Nächste Aktion ist Shutdown"""
|
|
config = NightModeConfig(
|
|
enabled=True,
|
|
shutdown_time="22:00",
|
|
startup_time="06:00"
|
|
)
|
|
|
|
# Mock datetime.now() auf 18:00
|
|
with patch('scheduler.datetime') as mock_dt:
|
|
mock_now = datetime(2026, 2, 9, 18, 0, 0)
|
|
mock_dt.now.return_value = mock_now
|
|
mock_dt.combine = datetime.combine
|
|
|
|
action, next_time, time_until = calculate_next_action(config)
|
|
assert action == "shutdown"
|
|
assert next_time is not None
|
|
assert next_time.hour == 22
|
|
assert next_time.minute == 0
|
|
|
|
def test_after_shutdown_before_midnight(self):
|
|
"""Nach Shutdown, vor Mitternacht: Nächste Aktion ist Startup morgen"""
|
|
config = NightModeConfig(
|
|
enabled=True,
|
|
shutdown_time="22:00",
|
|
startup_time="06:00"
|
|
)
|
|
|
|
with patch('scheduler.datetime') as mock_dt:
|
|
mock_now = datetime(2026, 2, 9, 23, 0, 0)
|
|
mock_dt.now.return_value = mock_now
|
|
mock_dt.combine = datetime.combine
|
|
|
|
action, next_time, time_until = calculate_next_action(config)
|
|
assert action == "startup"
|
|
assert next_time is not None
|
|
# Startup sollte am nächsten Tag sein
|
|
assert next_time.day == 10
|
|
|
|
def test_early_morning_before_startup(self):
|
|
"""Früher Morgen vor Startup: Nächste Aktion ist Startup heute"""
|
|
config = NightModeConfig(
|
|
enabled=True,
|
|
shutdown_time="22:00",
|
|
startup_time="06:00"
|
|
)
|
|
|
|
with patch('scheduler.datetime') as mock_dt:
|
|
mock_now = datetime(2026, 2, 9, 4, 0, 0)
|
|
mock_dt.now.return_value = mock_now
|
|
mock_dt.combine = datetime.combine
|
|
|
|
action, next_time, time_until = calculate_next_action(config)
|
|
assert action == "startup"
|
|
assert next_time is not None
|
|
assert next_time.hour == 6
|
|
|
|
|
|
class TestNightModeConfig:
|
|
"""Tests für das NightModeConfig Model"""
|
|
|
|
def test_default_config(self):
|
|
"""Standard-Konfiguration erstellen"""
|
|
config = NightModeConfig()
|
|
assert config.enabled is False
|
|
assert config.shutdown_time == "22:00"
|
|
assert config.startup_time == "06:00"
|
|
assert config.last_action is None
|
|
assert "night-scheduler" in config.excluded_services
|
|
|
|
def test_config_with_values(self):
|
|
"""Konfiguration mit Werten erstellen"""
|
|
config = NightModeConfig(
|
|
enabled=True,
|
|
shutdown_time="23:00",
|
|
startup_time="07:30",
|
|
last_action="startup",
|
|
last_action_time="2026-02-09T07:30:00"
|
|
)
|
|
assert config.enabled is True
|
|
assert config.shutdown_time == "23:00"
|
|
assert config.startup_time == "07:30"
|
|
assert config.last_action == "startup"
|
|
|
|
|
|
class TestAPIEndpoints:
|
|
"""Tests für die API Endpoints"""
|
|
|
|
def test_health_endpoint(self):
|
|
"""Health Endpoint gibt Status zurück"""
|
|
response = client.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert data["service"] == "night-scheduler"
|
|
|
|
def test_get_status_endpoint(self):
|
|
"""GET /api/night-mode gibt Status zurück"""
|
|
with patch('scheduler.load_config') as mock_load:
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
response = client.get("/api/night-mode")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "config" in data
|
|
assert "current_time" in data
|
|
|
|
def test_update_config_endpoint(self):
|
|
"""POST /api/night-mode aktualisiert Konfiguration"""
|
|
with patch('scheduler.save_config') as mock_save:
|
|
with patch('scheduler.load_config') as mock_load:
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
new_config = {
|
|
"enabled": True,
|
|
"shutdown_time": "23:00",
|
|
"startup_time": "07:00",
|
|
"excluded_services": ["night-scheduler", "nginx"]
|
|
}
|
|
|
|
response = client.post("/api/night-mode", json=new_config)
|
|
assert response.status_code == 200
|
|
mock_save.assert_called_once()
|
|
|
|
def test_update_config_invalid_time(self):
|
|
"""POST /api/night-mode mit ungültiger Zeit gibt Fehler"""
|
|
with patch('scheduler.load_config') as mock_load:
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
new_config = {
|
|
"enabled": True,
|
|
"shutdown_time": "invalid",
|
|
"startup_time": "06:00",
|
|
"excluded_services": []
|
|
}
|
|
|
|
response = client.post("/api/night-mode", json=new_config)
|
|
assert response.status_code == 400
|
|
|
|
def test_execute_stop_endpoint(self):
|
|
"""POST /api/night-mode/execute mit stop"""
|
|
with patch('scheduler.execute_docker_command') as mock_exec:
|
|
with patch('scheduler.load_config') as mock_load:
|
|
with patch('scheduler.save_config'):
|
|
mock_exec.return_value = (True, "Services gestoppt")
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
response = client.post(
|
|
"/api/night-mode/execute",
|
|
json={"action": "stop"}
|
|
)
|
|
assert response.status_code == 200
|
|
mock_exec.assert_called_once_with("stop")
|
|
|
|
def test_execute_start_endpoint(self):
|
|
"""POST /api/night-mode/execute mit start"""
|
|
with patch('scheduler.execute_docker_command') as mock_exec:
|
|
with patch('scheduler.load_config') as mock_load:
|
|
with patch('scheduler.save_config'):
|
|
mock_exec.return_value = (True, "Services gestartet")
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
response = client.post(
|
|
"/api/night-mode/execute",
|
|
json={"action": "start"}
|
|
)
|
|
assert response.status_code == 200
|
|
mock_exec.assert_called_once_with("start")
|
|
|
|
def test_execute_invalid_action(self):
|
|
"""POST /api/night-mode/execute mit ungültiger Aktion"""
|
|
response = client.post(
|
|
"/api/night-mode/execute",
|
|
json={"action": "invalid"}
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
def test_get_services_endpoint(self):
|
|
"""GET /api/night-mode/services gibt Services zurück"""
|
|
with patch('scheduler.get_services_to_manage') as mock_services:
|
|
with patch('scheduler.get_services_status') as mock_status:
|
|
with patch('scheduler.load_config') as mock_load:
|
|
mock_services.return_value = ["backend", "frontend"]
|
|
mock_status.return_value = {"backend": "running", "frontend": "running"}
|
|
mock_load.return_value = NightModeConfig()
|
|
|
|
response = client.get("/api/night-mode/services")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "all_services" in data
|
|
assert "excluded_services" in data
|
|
assert "status" in data
|
|
|
|
def test_get_logs_endpoint(self):
|
|
"""GET /api/night-mode/logs gibt Logs zurück"""
|
|
with patch('scheduler.load_config') as mock_load:
|
|
mock_load.return_value = NightModeConfig(
|
|
last_action="shutdown",
|
|
last_action_time="2026-02-09T22:00:00"
|
|
)
|
|
|
|
response = client.get("/api/night-mode/logs")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["last_action"] == "shutdown"
|
|
|
|
|
|
class TestConfigPersistence:
|
|
"""Tests für Konfigurations-Persistenz"""
|
|
|
|
def test_load_missing_config_returns_default(self):
|
|
"""Fehlende Konfiguration gibt Standard zurück"""
|
|
with patch.object(CONFIG_PATH, 'exists', return_value=False):
|
|
config = load_config()
|
|
assert config.enabled is False
|
|
assert config.shutdown_time == "22:00"
|
|
|
|
def test_save_and_load_config(self, tmp_path):
|
|
"""Konfiguration speichern und laden"""
|
|
config_file = tmp_path / "night-mode.json"
|
|
|
|
with patch('scheduler.CONFIG_PATH', config_file):
|
|
original = NightModeConfig(
|
|
enabled=True,
|
|
shutdown_time="21:00",
|
|
startup_time="05:30"
|
|
)
|
|
save_config(original)
|
|
|
|
loaded = load_config()
|
|
assert loaded.enabled == original.enabled
|
|
assert loaded.shutdown_time == original.shutdown_time
|
|
assert loaded.startup_time == original.startup_time
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|