44d7e96f29
Email/calendar/files routes now return 404 when the service is not installed, using a require_active_service decorator that checks ServiceRegistry. Status endpoints are exempt so health checks always work. SetupManager.complete_setup() now accepts a service_store_manager and installs any wizard-selected services in a background daemon thread after setup completes. Failures are logged but do not fail the wizard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
156 lines
6.8 KiB
Python
156 lines
6.8 KiB
Python
"""Tests for SetupManager service-install wiring added in Phase 6.
|
|
|
|
Verifies that complete_setup() triggers background service installs when
|
|
service_store_manager is provided and services_enabled is non-empty.
|
|
"""
|
|
import os
|
|
import sys
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
|
from setup_manager import SetupManager
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _valid_payload(**overrides):
|
|
base = {
|
|
'cell_name': 'mycel',
|
|
'password': 'SecurePass1!',
|
|
'domain_mode': 'lan',
|
|
'timezone': 'UTC',
|
|
'services_enabled': ['email'],
|
|
'ddns_provider': 'none',
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _make_setup_manager(services_enabled=None, service_store_manager=None):
|
|
config_mgr = MagicMock()
|
|
config_mgr.get_identity.return_value = {}
|
|
auth_mgr = MagicMock()
|
|
auth_mgr.create_user.return_value = True
|
|
return SetupManager(
|
|
config_manager=config_mgr,
|
|
auth_manager=auth_mgr,
|
|
service_store_manager=service_store_manager,
|
|
), config_mgr, auth_mgr
|
|
|
|
|
|
# ── constructor ───────────────────────────────────────────────────────────────
|
|
|
|
class TestSetupManagerConstructor(unittest.TestCase):
|
|
|
|
def test_accepts_service_store_manager_kwarg(self):
|
|
ssm = MagicMock()
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
self.assertIs(sm.service_store_manager, ssm)
|
|
|
|
def test_defaults_service_store_manager_to_none(self):
|
|
config_mgr = MagicMock()
|
|
config_mgr.get_identity.return_value = {}
|
|
auth_mgr = MagicMock()
|
|
sm = SetupManager(config_manager=config_mgr, auth_manager=auth_mgr)
|
|
self.assertIsNone(sm.service_store_manager)
|
|
|
|
|
|
# ── complete_setup + service install wiring ───────────────────────────────────
|
|
|
|
class TestCompleteSetupInstallWiring(unittest.TestCase):
|
|
|
|
def test_no_service_store_manager_does_not_crash(self, tmp_path=None):
|
|
"""service_store_manager=None with services_enabled set must not raise."""
|
|
sm, _, _ = _make_setup_manager(service_store_manager=None)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=['email']))
|
|
self.assertTrue(result['success'])
|
|
|
|
def test_empty_services_enabled_no_install_called(self):
|
|
"""install() must not be called when services_enabled is empty."""
|
|
ssm = MagicMock()
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=[]))
|
|
time.sleep(0.05)
|
|
ssm.install.assert_not_called()
|
|
self.assertTrue(result['success'])
|
|
|
|
def test_services_enabled_triggers_install_in_background(self):
|
|
"""install() must be called with each service id after complete_setup."""
|
|
ssm = MagicMock()
|
|
ssm.install.return_value = {'ok': True}
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=['email']))
|
|
self.assertTrue(result['success'])
|
|
# Give the daemon thread time to finish
|
|
time.sleep(0.1)
|
|
ssm.install.assert_called_with('email')
|
|
|
|
def test_multiple_services_all_installed(self):
|
|
ssm = MagicMock()
|
|
ssm.install.return_value = {'ok': True}
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(
|
|
_valid_payload(services_enabled=['email', 'calendar', 'files'])
|
|
)
|
|
self.assertTrue(result['success'])
|
|
time.sleep(0.1)
|
|
installed = [c[0][0] for c in ssm.install.call_args_list]
|
|
self.assertIn('email', installed)
|
|
self.assertIn('calendar', installed)
|
|
self.assertIn('files', installed)
|
|
|
|
def test_install_failure_does_not_fail_wizard(self):
|
|
"""An exception from install() must not propagate to complete_setup."""
|
|
ssm = MagicMock()
|
|
ssm.install.side_effect = RuntimeError('Docker daemon unreachable')
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=['email']))
|
|
self.assertTrue(result['success'])
|
|
self.assertEqual(result.get('redirect'), '/login')
|
|
|
|
def test_install_returning_error_dict_does_not_fail_wizard(self):
|
|
"""install() returning {'ok': False} must not affect setup result."""
|
|
ssm = MagicMock()
|
|
ssm.install.return_value = {'ok': False, 'error': 'image pull failed'}
|
|
sm, _, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=['email']))
|
|
self.assertTrue(result['success'])
|
|
|
|
def test_complete_setup_still_marks_setup_complete_before_install(self):
|
|
"""setup_complete must be persisted even if install thread races."""
|
|
ssm = MagicMock()
|
|
ssm.install.return_value = {'ok': True}
|
|
sm, config_mgr, _ = _make_setup_manager(service_store_manager=ssm)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
sm.complete_setup(_valid_payload(services_enabled=['email']))
|
|
# setup_complete must be the last set_identity_field call
|
|
calls = config_mgr.set_identity_field.call_args_list
|
|
last = calls[-1]
|
|
self.assertEqual(last, call('setup_complete', True))
|
|
|
|
def test_service_store_manager_none_with_empty_services_succeeds(self):
|
|
sm, _, _ = _make_setup_manager(service_store_manager=None)
|
|
with patch.dict(os.environ, {'DATA_DIR': self._tmp()}):
|
|
result = sm.complete_setup(_valid_payload(services_enabled=[]))
|
|
self.assertTrue(result['success'])
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────
|
|
|
|
def _tmp(self):
|
|
"""Return a writable temp dir path string for DATA_DIR."""
|
|
import tempfile
|
|
return tempfile.mkdtemp()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|