"""Tests for setup_service and settings_repo.""" from __future__ import annotations import asyncio import inspect from pathlib import Path import aiosqlite import pytest from app.db import init_db from app.repositories import settings_repo from app.services import setup_service @pytest.fixture async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] """Provide an initialised aiosqlite connection for service-level tests.""" conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "test.db")) conn.row_factory = aiosqlite.Row await init_db(conn) yield conn await conn.close() class TestIsSetupComplete: async def test_returns_false_on_fresh_db( self, db: aiosqlite.Connection ) -> None: """Setup is not complete on a fresh database.""" assert await setup_service.is_setup_complete(db) is False async def test_returns_true_after_run_setup( self, db: aiosqlite.Connection ) -> None: """Setup is marked complete after run_setup() succeeds.""" await setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) assert await setup_service.is_setup_complete(db) is True class TestRunSetup: async def test_persists_all_settings(self, db: aiosqlite.Connection) -> None: """run_setup() stores every provided setting.""" await setup_service.run_setup( db, master_password="mypassword1", database_path="/data/bangui.db", fail2ban_socket="/tmp/f2b.sock", timezone="Europe/Berlin", session_duration_minutes=120, ) all_settings = await settings_repo.get_all_settings(db) assert all_settings["database_path"] == "/data/bangui.db" assert all_settings["fail2ban_socket"] == "/tmp/f2b.sock" assert all_settings["timezone"] == "Europe/Berlin" assert all_settings["session_duration_minutes"] == "120" async def test_password_stored_as_bcrypt_hash( self, db: aiosqlite.Connection ) -> None: """The master password is stored as a bcrypt hash, not plain text.""" import bcrypt await setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) stored = await setup_service.get_password_hash(db) assert stored is not None assert stored != "mypassword1" # Verify it is a valid bcrypt hash. assert bcrypt.checkpw(b"mypassword1", stored.encode()) async def test_raises_if_setup_already_complete( self, db: aiosqlite.Connection ) -> None: """run_setup() raises RuntimeError if called a second time.""" kwargs = { "master_password": "mypassword1", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, } await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] with pytest.raises(RuntimeError, match="already been completed"): await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] async def test_initializes_map_color_thresholds_with_defaults( self, db: aiosqlite.Connection ) -> None: """run_setup() initializes map color thresholds with default values.""" await setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 class TestGetTimezone: async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None: """get_timezone() returns 'UTC' before setup is run.""" assert await setup_service.get_timezone(db) == "UTC" async def test_returns_configured_timezone( self, db: aiosqlite.Connection ) -> None: """get_timezone() returns the value set during setup.""" await setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="America/New_York", session_duration_minutes=60, ) assert await setup_service.get_timezone(db) == "America/New_York" class TestMapColorThresholds: async def test_get_map_color_thresholds_returns_defaults_on_fresh_db( self, db: aiosqlite.Connection ) -> None: """get_map_color_thresholds() returns default values on a fresh database.""" high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 async def test_set_map_color_thresholds_persists_values( self, db: aiosqlite.Connection ) -> None: """set_map_color_thresholds() stores and retrieves custom values.""" await setup_service.set_map_color_thresholds( db, threshold_high=200, threshold_medium=80, threshold_low=30 ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 200 assert medium == 80 assert low == 30 async def test_set_map_color_thresholds_rejects_non_positive( self, db: aiosqlite.Connection ) -> None: """set_map_color_thresholds() raises ValueError for non-positive thresholds.""" with pytest.raises(ValueError, match="positive integers"): await setup_service.set_map_color_thresholds( db, threshold_high=100, threshold_medium=50, threshold_low=0 ) with pytest.raises(ValueError, match="positive integers"): await setup_service.set_map_color_thresholds( db, threshold_high=-10, threshold_medium=50, threshold_low=20 ) async def test_set_map_color_thresholds_rejects_invalid_order( self, db: aiosqlite.Connection ) -> None: """ set_map_color_thresholds() rejects invalid ordering. """ with pytest.raises(ValueError, match="high > medium > low"): await setup_service.set_map_color_thresholds( db, threshold_high=50, threshold_medium=50, threshold_low=20 ) with pytest.raises(ValueError, match="high > medium > low"): await setup_service.set_map_color_thresholds( db, threshold_high=100, threshold_medium=30, threshold_low=50 ) async def test_run_setup_initializes_default_thresholds( self, db: aiosqlite.Connection ) -> None: """run_setup() initializes map color thresholds with defaults.""" await setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 class TestRunSetupAsync: """Verify the async/non-blocking bcrypt behavior of run_setup.""" async def test_run_setup_is_coroutine_function(self) -> None: """run_setup must be declared as an async function.""" assert inspect.iscoroutinefunction(setup_service.run_setup) async def test_password_hash_does_not_block_event_loop( self, db: aiosqlite.Connection ) -> None: """run_setup completes without blocking; other coroutines can interleave.""" async def noop() -> str: """A trivial coroutine that should run concurrently with setup.""" await asyncio.sleep(0) return "ok" setup_coro = setup_service.run_setup( db, master_password="mypassword1", database_path="bangui.db", fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) # Both tasks should finish without error. results = await asyncio.gather(setup_coro, noop()) assert results[1] == "ok" assert await setup_service.is_setup_complete(db) is True