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