Files
pic/tests/test_setup_manager_install_wiring.py
T
roof 44d7e96f29 feat: Phase 6 — require_active_service decorator + wizard install wiring
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>
2026-05-29 16:58:57 -04:00

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()