From 72ff8b67b569264f9389388ec364b321626f0ec6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:05:01 +0200 Subject: [PATCH 1/5] test: improve test coverage to 90%+ Add 10 new test files covering main routes, auth, database, catalog, compose generator, exchange rates, and all 14 collectors. Update CI to report coverage via Codecov. Add codecov.yml with 90% target (orchestrator.py and worker_api.py excluded as they require docker SDK). 777 tests passing, 93% coverage on tracked code. --- .github/workflows/test.yml | 15 +- codecov.yml | 13 + tests/test_auth_extended.py | 171 ++++ tests/test_catalog_loader.py | 167 ++++ tests/test_collectors_deep.py | 503 +++++++++++ tests/test_collectors_extended.py | 656 ++++++++++++++ tests/test_compose.py | 169 ++++ tests/test_database.py | 445 ++++++++++ tests/test_exchange_rates.py | 65 ++ tests/test_exchange_rates_extended.py | 58 ++ tests/test_main_deploy_routes.py | 554 ++++++++++++ tests/test_main_routes.py | 1153 +++++++++++++++++++++++++ 12 files changed, 3967 insertions(+), 2 deletions(-) create mode 100644 codecov.yml create mode 100644 tests/test_auth_extended.py create mode 100644 tests/test_catalog_loader.py create mode 100644 tests/test_collectors_deep.py create mode 100644 tests/test_collectors_extended.py create mode 100644 tests/test_compose.py create mode 100644 tests/test_database.py create mode 100644 tests/test_exchange_rates.py create mode 100644 tests/test_exchange_rates_extended.py create mode 100644 tests/test_main_deploy_routes.py create mode 100644 tests/test_main_routes.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e56fad2..3906d7f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,18 @@ jobs: - name: Install dependencies run: | pip install -r requirements.txt - pip install pytest pyyaml tzdata + pip install pytest pytest-cov pytest-asyncio pyyaml tzdata - name: Run tests - run: pytest tests/ -v + run: pytest tests/ -v --tb=short + + - name: Run tests with coverage + run: pytest tests/ --cov=app --cov-report=term-missing --cov-report=xml + + - name: Upload coverage reports + uses: codecov/codecov-action@v5 + with: + file: ./coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + continue-on-error: true diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..795904e --- /dev/null +++ b/codecov.yml @@ -0,0 +1,13 @@ +coverage: + status: + project: + default: + target: 90% + threshold: 2% + patch: + default: + target: 90% + +ignore: + - "app/orchestrator.py" + - "app/worker_api.py" diff --git a/tests/test_auth_extended.py b/tests/test_auth_extended.py new file mode 100644 index 0000000..fb83a65 --- /dev/null +++ b/tests/test_auth_extended.py @@ -0,0 +1,171 @@ +"""Extended tests for auth.py — covers get_current_user, session cookies, secret key resolution.""" + +import os +from unittest.mock import MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest + +from app import auth + + +class TestGetCurrentUser: + def _make_request(self, headers=None, cookies=None): + req = MagicMock() + req.headers = headers or {} + req.cookies = cookies or {} + return req + + def test_no_auth_returns_none(self): + req = self._make_request() + assert auth.get_current_user(req) is None + + def test_admin_api_key_returns_owner(self): + with patch.dict(os.environ, {"CASHPILOT_ADMIN_API_KEY": "admin-secret"}): + req = self._make_request(headers={"Authorization": "Bearer admin-secret"}) + user = auth.get_current_user(req) + assert user is not None + assert user["r"] == "owner" + assert user["u"] == "api" + + def test_fleet_key_returns_writer(self): + with patch("app.auth._fleet_key_mod.resolve_fleet_key", return_value="fleet-secret"): + req = self._make_request(headers={"Authorization": "Bearer fleet-secret"}) + user = auth.get_current_user(req) + assert user is not None + assert user["r"] == "writer" + + def test_invalid_bearer_falls_through_to_cookie(self): + req = self._make_request( + headers={"Authorization": "Bearer wrong"}, + cookies={}, + ) + with ( + patch.dict(os.environ, {"CASHPILOT_ADMIN_API_KEY": "admin-secret"}), + patch("app.auth._fleet_key_mod.resolve_fleet_key", return_value="fleet-secret"), + ): + assert auth.get_current_user(req) is None + + def test_valid_session_cookie(self): + token = auth.create_session_token(1, "alice", "owner") + req = self._make_request(cookies={auth.SESSION_COOKIE: token}) + user = auth.get_current_user(req) + assert user is not None + assert user["u"] == "alice" + assert user["r"] == "owner" + + def test_expired_session_cookie(self): + # Patch the serializer's loads to simulate expiration + with patch.object(auth, "decode_session_token", return_value=None): + token = auth.create_session_token(1, "alice", "owner") + req = self._make_request(cookies={auth.SESSION_COOKIE: token}) + user = auth.get_current_user(req) + assert user is None + + def test_tampered_session_cookie(self): + req = self._make_request(cookies={auth.SESSION_COOKIE: "tampered.garbage.token"}) + assert auth.get_current_user(req) is None + + +class TestSetSessionCookie: + def test_sets_cookie(self): + resp = MagicMock() + result = auth.set_session_cookie(resp, "test-token") + resp.set_cookie.assert_called_once() + args = resp.set_cookie.call_args + assert args[1]["httponly"] is True or args[0][1] == "test-token" + assert result is resp + + def test_secure_cookie_auto_https(self): + resp = MagicMock() + with ( + patch.object(auth, "_SECURE_COOKIE", "auto"), + patch.dict(os.environ, {"CASHPILOT_BASE_URL": "https://example.com"}), + ): + auth.set_session_cookie(resp, "tok") + call_kwargs = resp.set_cookie.call_args + assert call_kwargs[1].get("secure") is True or call_kwargs.kwargs.get("secure") is True + + def test_secure_cookie_forced_true(self): + resp = MagicMock() + with patch.object(auth, "_SECURE_COOKIE", "true"): + auth.set_session_cookie(resp, "tok") + call_kwargs = resp.set_cookie.call_args + assert call_kwargs[1].get("secure") is True or call_kwargs.kwargs.get("secure") is True + + +class TestClearSessionCookie: + def test_clears_cookie(self): + resp = MagicMock() + result = auth.clear_session_cookie(resp) + resp.delete_cookie.assert_called_once_with(auth.SESSION_COOKIE) + assert result is resp + + +class TestRequireRole: + def test_none_user(self): + assert auth.require_role(None, "owner") is False + + def test_matching_role(self): + assert auth.require_role({"r": "owner"}, "owner") is True + + def test_multiple_roles(self): + assert auth.require_role({"r": "writer"}, "owner", "writer") is True + + def test_non_matching_role(self): + assert auth.require_role({"r": "viewer"}, "owner", "writer") is False + + +class TestDecodeSessionToken: + def test_valid_token(self): + token = auth.create_session_token(5, "bob", "viewer") + data = auth.decode_session_token(token) + assert data is not None + assert data["uid"] == 5 + assert data["u"] == "bob" + assert data["r"] == "viewer" + + def test_invalid_token(self): + assert auth.decode_session_token("garbage") is None + + def test_empty_token(self): + assert auth.decode_session_token("") is None + + +class TestResolveSecretKey: + def test_env_var_overrides(self): + with patch.dict(os.environ, {"CASHPILOT_SECRET_KEY": "my-strong-secret-key-here"}): + result = auth._resolve_secret_key() + assert result == "my-strong-secret-key-here" + + def test_known_default_ignored(self): + with ( + patch.dict(os.environ, {"CASHPILOT_SECRET_KEY": "changeme"}), + patch("app.auth.Path") as mock_path_cls, + ): + mock_data_dir = MagicMock() + mock_key_file = MagicMock() + mock_key_file.is_file.return_value = False + mock_data_dir.__truediv__ = MagicMock(return_value=mock_key_file) + mock_path_cls.return_value = mock_data_dir + result = auth._resolve_secret_key() + # Should generate a new key, not return "changeme" + assert result != "changeme" + + def test_reads_persisted_key(self, tmp_path): + key_file = tmp_path / ".secret_key" + key_file.write_text("persisted-secret-key") + with ( + patch.dict(os.environ, {"CASHPILOT_SECRET_KEY": "", "CASHPILOT_DATA_DIR": str(tmp_path)}), + ): + result = auth._resolve_secret_key() + assert result == "persisted-secret-key" + + def test_generates_and_persists_key(self, tmp_path): + with ( + patch.dict(os.environ, {"CASHPILOT_SECRET_KEY": "", "CASHPILOT_DATA_DIR": str(tmp_path)}), + ): + result = auth._resolve_secret_key() + assert len(result) > 20 + assert (tmp_path / ".secret_key").read_text().strip() == result diff --git a/tests/test_catalog_loader.py b/tests/test_catalog_loader.py new file mode 100644 index 0000000..f92e5cb --- /dev/null +++ b/tests/test_catalog_loader.py @@ -0,0 +1,167 @@ +"""Tests for the catalog module's load/get logic.""" + +import os +from pathlib import Path +from unittest.mock import patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import yaml + +from app import catalog + + +def _make_service_yaml(slug="test-svc", name="Test Service", category="bandwidth", + status="active", description="A test service", + docker=None): + data = { + "name": name, + "slug": slug, + "category": category, + "status": status, + "description": description, + "docker": docker or {"image": "test/image:latest"}, + } + return yaml.dump(data) + + +class TestLoadFromDisk: + def test_loads_yml_files(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "testsvc.yml").write_text(_make_service_yaml("testsvc")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + assert services[0]["slug"] == "testsvc" + + def test_skips_underscore_files(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "_schema.yml").write_text(_make_service_yaml("schema")) + (svc_dir / "real.yml").write_text(_make_service_yaml("real")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + assert services[0]["slug"] == "real" + + def test_skips_invalid_yaml(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "bad.yml").write_text("{{{{invalid yaml") + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_skips_non_dict_yaml(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "list.yml").write_text("- item1\n- item2\n") + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_skips_missing_required_fields(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "incomplete.yml").write_text(yaml.dump({"name": "Only Name"})) + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_missing_services_dir(self, tmp_path): + with patch.object(catalog, "SERVICES_DIR", tmp_path / "nonexistent"): + services = catalog._load_from_disk() + assert services == [] + + def test_loads_yaml_extension(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "svc.yaml").write_text(_make_service_yaml("svc")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + +class TestCatalogCache: + def test_load_services_populates_cache(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "cached.yml").write_text(_make_service_yaml("cached")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + result = catalog.load_services() + assert len(result) == 1 + + def test_get_service_by_slug(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "mysvc.yml").write_text(_make_service_yaml("mysvc")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + catalog.load_services() + svc = catalog.get_service("mysvc") + assert svc is not None + assert svc["slug"] == "mysvc" + + def test_get_service_missing_returns_none(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "x.yml").write_text(_make_service_yaml("x")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + catalog.load_services() + assert catalog.get_service("nonexistent") is None + + def test_get_services_returns_copies(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "svc.yml").write_text(_make_service_yaml("svc")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + catalog.load_services() + services1 = catalog.get_services() + services1[0]["name"] = "MODIFIED" + services2 = catalog.get_services() + assert services2[0]["name"] != "MODIFIED" + + def test_get_services_by_category(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "a.yml").write_text(_make_service_yaml("a", category="bandwidth")) + (svc_dir / "b.yml").write_text(_make_service_yaml("b", category="depin")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + catalog.load_services() + grouped = catalog.get_services_by_category() + assert "bandwidth" in grouped + assert "depin" in grouped + + +class TestValidate: + def test_validate_valid(self, tmp_path): + data = { + "name": "Test", + "slug": "test", + "category": "bandwidth", + "status": "active", + "description": "desc", + "docker": {"image": "test:latest"}, + } + errors = catalog._validate(data, tmp_path / "test.yml") + assert errors == [] + + def test_validate_missing_fields(self, tmp_path): + data = {"name": "Test"} + errors = catalog._validate(data, tmp_path / "test.yml") + assert len(errors) == 1 + assert "missing" in errors[0] diff --git a/tests/test_collectors_deep.py b/tests/test_collectors_deep.py new file mode 100644 index 0000000..5fcd471 --- /dev/null +++ b/tests/test_collectors_deep.py @@ -0,0 +1,503 @@ +"""Deep collector tests covering auth refresh paths, specific response parsing, +and edge cases that the basic tests didn't cover.""" + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest + + +def _make_async_client(): + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +def _mock_response(status_code=200, json_data=None, text="", url="https://example.com"): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.text = text + resp.url = url + resp.headers = {} + resp.raise_for_status = MagicMock() + if status_code >= 400: + import httpx + resp.raise_for_status.side_effect = httpx.HTTPStatusError( + f"HTTP {status_code}", request=MagicMock(), response=resp + ) + return resp + + +# --------------------------------------------------------------------------- +# MystNodes — comprehensive +# --------------------------------------------------------------------------- + + +class TestMystNodesCollectorDeep: + def test_collect_success(self): + from app.collectors.mystnodes import MystNodesCollector + + login_resp = _mock_response(200, {"accessToken": "at", "refreshToken": "rt"}) + earnings_resp = _mock_response(200, {"earningsTotal": 12.5}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = earnings_resp + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 12.5 + assert result.currency == "MYST" + + def test_collect_no_credentials(self): + from app.collectors.mystnodes import MystNodesCollector + + c = MystNodesCollector(email="", password="") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "not configured" in result.error + + def test_collect_with_token_refresh(self): + from app.collectors.mystnodes import MystNodesCollector + + login_resp = _mock_response(200, {"accessToken": "at", "refreshToken": "rt"}) + expired_resp = MagicMock() + expired_resp.status_code = 401 + ok_resp = _mock_response(200, {"earningsTotal": 5.0}) + + refresh_resp = _mock_response(200, {"accessToken": "new-at", "refreshToken": "new-rt"}) + + client = _make_async_client() + client.post.side_effect = [login_resp, refresh_resp] + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 5.0 + + def test_get_per_node_earnings(self): + from app.collectors.mystnodes import MystNodesCollector + + login_resp = _mock_response(200, {"accessToken": "at", "refreshToken": "rt"}) + nodes_resp = _mock_response(200, { + "nodes": [ + { + "identity": "0xabc123", + "name": "node-1", + "localIp": "192.168.1.10", + "nodeStatus": {"online": True}, + "country": {"code": "US"}, + "version": "1.0.0", + "earnings": [{"etherAmount": 0.5}, {"etherAmount": 0.3}], + "lifetimeEarnings": { + "totalEther": 10.0, + "settledEther": 8.0, + "unsettledEther": 2.0, + }, + } + ] + }) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = nodes_resp + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="test@test.com", password="pass") + result = asyncio.run(c.get_per_node_earnings()) + assert len(result) == 1 + assert result[0]["identity"] == "0xabc123" + assert result[0]["earnings_myst"] == 0.8 + assert result[0]["online"] is True + + def test_get_per_node_no_creds(self): + from app.collectors.mystnodes import MystNodesCollector + + c = MystNodesCollector(email="", password="") + result = asyncio.run(c.get_per_node_earnings()) + assert result == [] + + def test_get_per_node_error(self): + from app.collectors.mystnodes import MystNodesCollector + + client = _make_async_client() + client.post.side_effect = Exception("Auth failed") + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="test@test.com", password="pass") + result = asyncio.run(c.get_per_node_earnings()) + assert result == [] + + +# --------------------------------------------------------------------------- +# Traffmonetizer — auth path +# --------------------------------------------------------------------------- + + +class TestTraffmonetizerDeep: + def test_collect_with_email_auth(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + login_resp = _mock_response(200, {"data": {"token": "jwt-tok"}}) + balance_resp = _mock_response(200, {"data": {"balance": 1.50}}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.50 + + def test_collect_token_refresh_on_401(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + expired_resp = MagicMock() + expired_resp.status_code = 401 + login_resp = _mock_response(200, {"data": {"token": "new-tok"}}) + ok_resp = _mock_response(200, {"data": {"balance": 0.75}}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(email="test@test.com", password="pass", token="old-tok") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 0.75 + + def test_collect_auth_permanently_failed(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + resp_401 = MagicMock() + resp_401.status_code = 401 + + client = _make_async_client() + client.get.return_value = resp_401 + # No email set, so no re-auth possible + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(token="expired-tok") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "Authentication" in result.error + + +# --------------------------------------------------------------------------- +# EarnFM — auth refresh path +# --------------------------------------------------------------------------- + + +class TestEarnFMDeep: + def test_collect_with_token_refresh(self): + from app.collectors.earnfm import EarnFMCollector + + login_resp = _mock_response(200, {"access_token": "at", "refresh_token": "rt"}) + expired_resp = MagicMock() + expired_resp.status_code = 401 + refresh_resp = _mock_response(200, {"access_token": "new-at", "refresh_token": "new-rt"}) + ok_resp = _mock_response(200, {"data": {"totalBalance": 1.25}}) + + client = _make_async_client() + client.post.side_effect = [login_resp, refresh_resp] + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.earnfm.httpx.AsyncClient", return_value=client): + c = EarnFMCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.25 + + +# --------------------------------------------------------------------------- +# Repocket — auth refresh path +# --------------------------------------------------------------------------- + + +class TestRepocketDeep: + def test_collect_with_token_refresh(self): + from app.collectors.repocket import RepocketCollector + + login_resp = _mock_response(200, {"idToken": "id-tok", "refreshToken": "ref-tok"}) + expired_resp = MagicMock() + expired_resp.status_code = 401 + refresh_resp = _mock_response(200, {"id_token": "new-id", "refresh_token": "new-ref"}) + ok_resp = _mock_response(200, {"centsCredited": 200}) + + client = _make_async_client() + client.post.side_effect = [login_resp, refresh_resp] + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.repocket.httpx.AsyncClient", return_value=client): + c = RepocketCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 2.0 + + +# --------------------------------------------------------------------------- +# Grass — active devices estimation +# --------------------------------------------------------------------------- + + +class TestGrassDeep: + def test_collect_active_devices_estimation(self): + from app.collectors.grass import GrassCollector + + # First call: settled points = 0 (active epoch) + user_resp = _mock_response(200, {"result": {"data": {"totalPoints": 0}}}) + # Second call: active devices + devices_resp = _mock_response(200, { + "result": {"data": [ + {"aggUptime": 3600, "ipScore": 80, "multiplier": 1.0, "ipAddress": "1.2.3.4"}, + ]} + }) + + client = _make_async_client() + client.get.side_effect = [user_resp, devices_resp] + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="test-token") + result = asyncio.run(c.collect()) + assert result.error is None + # 1 hour * 50 base * (80/100) * 1.0 = 40 points + assert result.balance == 40.0 + + def test_collect_rate_limited(self): + from app.collectors.grass import GrassCollector + + rate_resp = MagicMock() + rate_resp.status_code = 429 + rate_resp.headers = {"Retry-After": "1"} + + client = _make_async_client() + client.get.return_value = rate_resp + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="test-token") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "rate limit" in result.error.lower() + + def test_collect_active_devices_rate_limited(self): + from app.collectors.grass import GrassCollector + + user_resp = _mock_response(200, {"result": {"data": {"totalPoints": 0}}}) + rate_resp = MagicMock() + rate_resp.status_code = 429 + rate_resp.headers = {"Retry-After": "1"} + + client = _make_async_client() + client.get.side_effect = [user_resp, rate_resp] + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="test-token") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# PacketStream — alternative parsing +# --------------------------------------------------------------------------- + + +class TestPacketStreamDeep: + def test_collect_json_pattern(self): + from app.collectors.packetstream import PacketStreamCollector + + html = 'window.userData = {"balance": 2.50}' + resp = _mock_response(200, url="https://app.packetstream.io/dashboard") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="jwt") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 2.50 + + def test_collect_bare_json_pattern(self): + from app.collectors.packetstream import PacketStreamCollector + + html = '{"something": true, "balance": 1.75, "more": false}' + resp = _mock_response(200, url="https://app.packetstream.io/dashboard") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="jwt") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.75 + + def test_collect_unparseable_html(self): + from app.collectors.packetstream import PacketStreamCollector + + html = "No balance here" + resp = _mock_response(200, url="https://app.packetstream.io/dashboard") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="jwt") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "parse" in result.error.lower() + + +# --------------------------------------------------------------------------- +# Storj — alternative response formats +# --------------------------------------------------------------------------- + + +class TestStorjDeep: + def test_collect_estimated_payout_field(self): + from app.collectors.storj import StorjCollector + + resp = _mock_response(200, {"estimatedPayout": 250}) + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector() + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 2.50 + + def test_collect_fallback_to_sno_on_404(self): + from app.collectors.storj import StorjCollector + + not_found = MagicMock() + not_found.status_code = 404 + sno_resp = _mock_response(200, {"currentMonthExpectations": 150}) + + client = _make_async_client() + client.get.side_effect = [not_found, sno_resp] + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector() + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.50 + + +# --------------------------------------------------------------------------- +# Bytelixir — API fallback path +# --------------------------------------------------------------------------- + + +class TestBytelixirDeep: + def test_collect_api_fallback(self): + from app.collectors.bytelixir import BytelixirCollector + + # HTML scrape fails (no balance pattern) + html_resp = _mock_response(200, text="

no balance

", url="https://dash.bytelixir.com/en") + html_resp.text = "

no balance

" + # API fallback succeeds + api_resp = _mock_response(200, {"data": {"balance": "0.5000000000"}}) + + client = _make_async_client() + client.get.side_effect = [html_resp, api_resp] + + with patch.object(BytelixirCollector, "_make_client", return_value=client): + c = BytelixirCollector(session_cookie="valid") + result = asyncio.run(c.collect()) + assert result.balance == 0.50 + + def test_collect_api_401(self): + from app.collectors.bytelixir import BytelixirCollector + + html_resp = _mock_response(200, text="

no balance

", url="https://dash.bytelixir.com/en") + html_resp.text = "

no balance

" + api_resp = _mock_response(401) + api_resp.raise_for_status = MagicMock() # 401 handled inline + + client = _make_async_client() + client.get.side_effect = [html_resp, api_resp] + + with patch.object(BytelixirCollector, "_make_client", return_value=client): + c = BytelixirCollector(session_cookie="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "expired" in result.error.lower() + + +# --------------------------------------------------------------------------- +# EarnApp — success with cookies mock +# --------------------------------------------------------------------------- + + +class TestEarnAppDeep: + def test_collect_success(self): + from app.collectors.earnapp import EarnAppCollector + + xsrf_resp = _mock_response(200) + balance_resp = _mock_response(200, {"balance": 3.25}) + + client = _make_async_client() + client.cookies = MagicMock() + client.cookies.items.return_value = [("xsrf-token", "xsrf-val")] + client.get.side_effect = [xsrf_resp, balance_resp] + + with patch("app.collectors.earnapp.httpx.AsyncClient", return_value=client): + c = EarnAppCollector(oauth_token="test-token") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 3.25 + + def test_collect_error_in_response(self): + from app.collectors.earnapp import EarnAppCollector + + xsrf_resp = _mock_response(200) + error_resp = _mock_response(200, {"error": "Session expired"}) + + client = _make_async_client() + client.cookies = MagicMock() + client.cookies.items.return_value = [] + client.get.side_effect = [xsrf_resp, error_resp] + + with patch("app.collectors.earnapp.httpx.AsyncClient", return_value=client): + c = EarnAppCollector(oauth_token="test-token") + result = asyncio.run(c.collect()) + assert result.error == "Session expired" + + +# --------------------------------------------------------------------------- +# Bitping — success with cookies mock +# --------------------------------------------------------------------------- + + +class TestBitpingDeep: + def test_collect_token_refresh(self): + from app.collectors.bitping import BitpingCollector + + login_resp = _mock_response(200, {"token": "tok"}) + expired_resp = MagicMock() + expired_resp.status_code = 401 + ok_resp = _mock_response(200, {"usdEarnings": 0.25}) + + client = _make_async_client() + client.cookies = MagicMock() + client.cookies.items.return_value = [("token", "tok123")] + client.post.return_value = login_resp + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.bitping.httpx.AsyncClient", return_value=client): + c = BitpingCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 0.25 diff --git a/tests/test_collectors_extended.py b/tests/test_collectors_extended.py new file mode 100644 index 0000000..924ccdb --- /dev/null +++ b/tests/test_collectors_extended.py @@ -0,0 +1,656 @@ +"""Extended tests for individual collector classes. + +Each collector follows the same pattern: authenticate + fetch balance via httpx. +We mock httpx.AsyncClient to test collect() logic without network calls. +""" + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest + +from app.collectors.base import BaseCollector, EarningsResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _mock_response(status_code=200, json_data=None, text="", url="https://example.com"): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.text = text + resp.url = url + resp.headers = {} + resp.raise_for_status = MagicMock() + if status_code >= 400: + import httpx as _httpx + resp.raise_for_status.side_effect = _httpx.HTTPStatusError( + f"HTTP {status_code}", request=MagicMock(), response=resp + ) + return resp + + +def _make_async_client(): + """Create a base mock AsyncClient with proper async context manager.""" + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +# --------------------------------------------------------------------------- +# BaseCollector +# --------------------------------------------------------------------------- + + +class TestBaseCollector: + def test_base_raises_not_implemented(self): + c = BaseCollector() + with pytest.raises(NotImplementedError): + asyncio.run(c.collect()) + + +# --------------------------------------------------------------------------- +# Honeygain +# --------------------------------------------------------------------------- + + +class TestHoneygainCollector: + def test_collect_success(self): + from app.collectors.honeygain import HoneygainCollector + + login_resp = _mock_response(200, {"data": {"access_token": "jwt-token"}}) + balance_resp = _mock_response(200, {"data": {"payout": {"usd_cents": 550}}}) + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.honeygain.httpx.AsyncClient", return_value=client): + c = HoneygainCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.balance == 5.50 + assert result.error is None + + def test_collect_auth_failure(self): + from app.collectors.honeygain import HoneygainCollector + + client = _make_async_client() + client.post.side_effect = Exception("Auth failed") + + with patch("app.collectors.honeygain.httpx.AsyncClient", return_value=client): + c = HoneygainCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + assert result.balance == 0.0 + + def test_collect_token_refresh(self): + from app.collectors.honeygain import HoneygainCollector + + login_resp = _mock_response(200, {"data": {"access_token": "new-jwt"}}) + expired_resp = MagicMock() + expired_resp.status_code = 401 + ok_resp = _mock_response(200, {"data": {"payout": {"usd_cents": 100}}}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.side_effect = [expired_resp, ok_resp] + + with patch("app.collectors.honeygain.httpx.AsyncClient", return_value=client): + c = HoneygainCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.balance == 1.0 + assert result.error is None + + +# --------------------------------------------------------------------------- +# EarnApp — uses client.cookies internally, test error path reliably +# --------------------------------------------------------------------------- + + +class TestEarnAppCollector: + def test_collect_error(self): + from app.collectors.earnapp import EarnAppCollector + + client = _make_async_client() + client.get.side_effect = Exception("Network error") + + with patch("app.collectors.earnapp.httpx.AsyncClient", return_value=client): + c = EarnAppCollector(oauth_token="bad-token") + result = asyncio.run(c.collect()) + assert result.error is not None + assert result.balance == 0.0 + + def test_collect_403(self): + """Test authentication failure path.""" + from app.collectors.earnapp import EarnAppCollector + + xsrf_resp = _mock_response(200) + forbidden_resp = _mock_response(403) + forbidden_resp.raise_for_status = MagicMock() # 403 is handled, not raised + + client = _make_async_client() + # cookies.items() is called after the first GET + client.cookies = httpx_cookies_mock({}) + client.get.side_effect = [xsrf_resp, forbidden_resp] + + with patch("app.collectors.earnapp.httpx.AsyncClient", return_value=client): + c = EarnAppCollector(oauth_token="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "Authentication" in result.error + + +def httpx_cookies_mock(cookie_dict): + """Create a mock that behaves like httpx.Cookies for .items() iteration.""" + mock = MagicMock() + mock.items.return_value = list(cookie_dict.items()) + return mock + + +# --------------------------------------------------------------------------- +# IPRoyal +# --------------------------------------------------------------------------- + + +class TestIPRoyalCollector: + def test_collect_success(self): + from app.collectors.iproyal import IPRoyalCollector + + login_resp = _mock_response(200, {"access_token": "tok"}) + balance_resp = _mock_response(200, {"balance": 4.50}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.iproyal.httpx.AsyncClient", return_value=client): + c = IPRoyalCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 4.50 + + def test_collect_login_failure(self): + from app.collectors.iproyal import IPRoyalCollector + + login_resp = _mock_response(422) + login_resp.raise_for_status = MagicMock() # 422 is handled inline + + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.iproyal.httpx.AsyncClient", return_value=client): + c = IPRoyalCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + def test_collect_network_error(self): + from app.collectors.iproyal import IPRoyalCollector + + client = _make_async_client() + client.post.side_effect = Exception("Network error") + + with patch("app.collectors.iproyal.httpx.AsyncClient", return_value=client): + c = IPRoyalCollector(email="x", password="x") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Traffmonetizer +# --------------------------------------------------------------------------- + + +class TestTraffmonetizerCollector: + def test_collect_success_with_token(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + balance_resp = _mock_response(200, {"data": {"balance": 2.75}}) + client = _make_async_client() + client.get.return_value = balance_resp + + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(token="jwt-token") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 2.75 + + def test_collect_no_token_no_creds(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + client = _make_async_client() + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector() + result = asyncio.run(c.collect()) + assert result.error is not None + assert "No token" in result.error + + def test_collect_error(self): + from app.collectors.traffmonetizer import TraffmonetizerCollector + + client = _make_async_client() + client.get.side_effect = Exception("Network error") + + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(token="bad-token") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Repocket +# --------------------------------------------------------------------------- + + +class TestRepocketCollector: + def test_collect_success(self): + from app.collectors.repocket import RepocketCollector + + login_resp = _mock_response(200, {"idToken": "id-tok", "refreshToken": "ref-tok"}) + balance_resp = _mock_response(200, {"centsCredited": 150}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.repocket.httpx.AsyncClient", return_value=client): + c = RepocketCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.50 + + def test_collect_error(self): + from app.collectors.repocket import RepocketCollector + + client = _make_async_client() + client.post.side_effect = Exception("Login failed") + + with patch("app.collectors.repocket.httpx.AsyncClient", return_value=client): + c = RepocketCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# ProxyRack — uses POST for balance +# --------------------------------------------------------------------------- + + +class TestProxyRackCollector: + def test_collect_success(self): + from app.collectors.proxyrack import ProxyRackCollector + + balance_resp = _mock_response(200, {"data": {"balance": "$0.85"}}) + client = _make_async_client() + client.post.return_value = balance_resp + + with patch("app.collectors.proxyrack.httpx.AsyncClient", return_value=client): + c = ProxyRackCollector(api_key="test-api-key") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 0.85 + + def test_collect_auth_failure(self): + from app.collectors.proxyrack import ProxyRackCollector + + resp = _mock_response(401) + resp.raise_for_status = MagicMock() # 401 handled inline + client = _make_async_client() + client.post.return_value = resp + + with patch("app.collectors.proxyrack.httpx.AsyncClient", return_value=client): + c = ProxyRackCollector(api_key="bad-key") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "Authentication" in result.error + + def test_collect_error(self): + from app.collectors.proxyrack import ProxyRackCollector + + client = _make_async_client() + client.post.side_effect = Exception("API error") + + with patch("app.collectors.proxyrack.httpx.AsyncClient", return_value=client): + c = ProxyRackCollector(api_key="bad-key") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Bitping — uses client.cookies like EarnApp, test error path +# --------------------------------------------------------------------------- + + +class TestBitpingCollector: + def test_collect_error(self): + from app.collectors.bitping import BitpingCollector + + client = _make_async_client() + client.post.side_effect = Exception("Auth error") + + with patch("app.collectors.bitping.httpx.AsyncClient", return_value=client): + c = BitpingCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + assert result.balance == 0.0 + + def test_collect_success(self): + from app.collectors.bitping import BitpingCollector + + login_resp = _mock_response(200, {"token": "tok123"}) + balance_resp = _mock_response(200, {"usdEarnings": 0.15}) + + client = _make_async_client() + # Bitping checks client.cookies for "token" cookie after login + client.cookies = httpx_cookies_mock({"token": "tok123"}) + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.bitping.httpx.AsyncClient", return_value=client): + c = BitpingCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 0.15 + + +# --------------------------------------------------------------------------- +# EarnFM +# --------------------------------------------------------------------------- + + +class TestEarnFMCollector: + def test_collect_success(self): + from app.collectors.earnfm import EarnFMCollector + + login_resp = _mock_response(200, {"access_token": "auth-tok", "refresh_token": "ref-tok"}) + balance_resp = _mock_response(200, {"data": {"totalBalance": 0.50}}) + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.earnfm.httpx.AsyncClient", return_value=client): + c = EarnFMCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 0.50 + + def test_collect_error(self): + from app.collectors.earnfm import EarnFMCollector + + client = _make_async_client() + client.post.side_effect = Exception("Failed") + + with patch("app.collectors.earnfm.httpx.AsyncClient", return_value=client): + c = EarnFMCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# PacketStream — scrapes HTML +# --------------------------------------------------------------------------- + + +class TestPacketStreamCollector: + def test_collect_success_html_pattern(self): + from app.collectors.packetstream import PacketStreamCollector + + html = '

Balance

$1.25

' + resp = _mock_response(200, text=html, url="https://app.packetstream.io/dashboard") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="jwt-token") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.25 + + def test_collect_auth_failure(self): + from app.collectors.packetstream import PacketStreamCollector + + resp = _mock_response(200, url="https://app.packetstream.io/login") + resp.raise_for_status = MagicMock() + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "Authentication" in result.error + + def test_collect_error(self): + from app.collectors.packetstream import PacketStreamCollector + + client = _make_async_client() + client.get.side_effect = Exception("Error") + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Grass — complex multi-endpoint +# --------------------------------------------------------------------------- + + +class TestGrassCollector: + def test_collect_settled_points(self): + from app.collectors.grass import GrassCollector + + resp = _mock_response(200, {"result": {"data": {"totalPoints": 250.0}}}) + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="test-token") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 250.0 + assert result.currency == "GRASS" + + def test_collect_auth_failure(self): + from app.collectors.grass import GrassCollector + + resp = _mock_response(401) + resp.raise_for_status = MagicMock() + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "Token expired" in result.error + + def test_collect_error(self): + from app.collectors.grass import GrassCollector + + client = _make_async_client() + client.get.side_effect = Exception("Error") + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Bytelixir — session cookie scraper +# --------------------------------------------------------------------------- + + +class TestBytelixirCollector: + def test_collect_session_expired(self): + from app.collectors.bytelixir import BytelixirCollector + + resp = _mock_response(200, url="https://dash.bytelixir.com/login") + client = _make_async_client() + client.get.return_value = resp + + with patch.object(BytelixirCollector, "_make_client", return_value=client): + c = BytelixirCollector(session_cookie="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "expired" in result.error.lower() + + def test_collect_html_scrape_success(self): + from app.collectors.bytelixir import BytelixirCollector + + html = '$0.04025' + resp = _mock_response(200, text=html, url="https://dash.bytelixir.com/en") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch.object(BytelixirCollector, "_make_client", return_value=client): + c = BytelixirCollector(session_cookie="valid-sess") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == pytest.approx(0.0403, abs=0.001) + + def test_collect_error(self): + from app.collectors.bytelixir import BytelixirCollector + + client = _make_async_client() + client.get.side_effect = Exception("Error") + + with patch.object(BytelixirCollector, "_make_client", return_value=client): + c = BytelixirCollector(session_cookie="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + def test_parse_balance_from_html(self): + from app.collectors.bytelixir import BytelixirCollector + + html = '$1.23456' + assert BytelixirCollector._parse_balance_from_html(html) == 1.23456 + + def test_parse_balance_no_match(self): + from app.collectors.bytelixir import BytelixirCollector + + assert BytelixirCollector._parse_balance_from_html("

nothing

") is None + + +# --------------------------------------------------------------------------- +# Salad +# --------------------------------------------------------------------------- + + +class TestSaladCollector: + def test_collect_success(self): + from app.collectors.salad import SaladCollector + + balance_resp = _mock_response(200, {"currentBalance": 1.10}) + client = _make_async_client() + client.get.return_value = balance_resp + + with patch("app.collectors.salad.httpx.AsyncClient", return_value=client): + c = SaladCollector(auth_cookie="auth-cookie") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 1.10 + + def test_collect_auth_expired(self): + from app.collectors.salad import SaladCollector + + resp = _mock_response(401) + resp.raise_for_status = MagicMock() # 401 handled inline + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.salad.httpx.AsyncClient", return_value=client): + c = SaladCollector(auth_cookie="expired") + result = asyncio.run(c.collect()) + assert result.error is not None + assert "expired" in result.error.lower() + + def test_collect_error(self): + from app.collectors.salad import SaladCollector + + client = _make_async_client() + client.get.side_effect = Exception("Error") + + with patch("app.collectors.salad.httpx.AsyncClient", return_value=client): + c = SaladCollector(auth_cookie="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# Storj +# --------------------------------------------------------------------------- + + +class TestStorjCollector: + def test_collect_success_current_month(self): + from app.collectors.storj import StorjCollector + + data = { + "currentMonth": { + "egressBandwidthPayout": 150, + "egressRepairAuditPayout": 50, + "diskSpacePayout": 100, + } + } + resp = _mock_response(200, data) + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector(api_url="http://localhost:14002") + result = asyncio.run(c.collect()) + assert result.error is None + assert result.balance == 3.0 # (150+50+100)/100 + + def test_collect_connect_error(self): + from app.collectors.storj import StorjCollector + import httpx as _httpx + + client = _make_async_client() + client.get.side_effect = _httpx.ConnectError("Connection refused") + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector() + result = asyncio.run(c.collect()) + assert result.error is not None + assert "not reachable" in result.error + + def test_collect_generic_error(self): + from app.collectors.storj import StorjCollector + + client = _make_async_client() + client.get.side_effect = Exception("Error") + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector() + result = asyncio.run(c.collect()) + assert result.error is not None + + +# --------------------------------------------------------------------------- +# MystNodes +# --------------------------------------------------------------------------- + + +class TestMystNodesCollector: + def test_collect_error(self): + from app.collectors.mystnodes import MystNodesCollector + + client = _make_async_client() + client.post.side_effect = Exception("Auth error") + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="bad", password="bad") + result = asyncio.run(c.collect()) + assert result.error is not None + assert result.balance == 0.0 diff --git a/tests/test_compose.py b/tests/test_compose.py new file mode 100644 index 0000000..e773645 --- /dev/null +++ b/tests/test_compose.py @@ -0,0 +1,169 @@ +"""Tests for compose file generation.""" + +import os +from unittest.mock import patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest +import yaml + +from app import compose_generator + + +def _mock_service(slug="honeygain", name="Honeygain", image="honeygain/honeygain:latest", + env=None, ports=None, volumes=None, category="bandwidth", + network_mode=None, cap_add=None, command=None): + svc = { + "name": name, + "slug": slug, + "category": category, + "status": "active", + "description": "Test service", + "docker": { + "image": image, + "env": env or [], + "ports": ports or [], + "volumes": volumes or [], + }, + } + if network_mode: + svc["docker"]["network_mode"] = network_mode + if cap_add: + svc["docker"]["cap_add"] = cap_add + if command: + svc["docker"]["command"] = command + return svc + + +class TestServiceToCompose: + def test_basic_service(self): + svc = _mock_service() + result = compose_generator._service_to_compose(svc) + assert result is not None + assert result["image"] == "honeygain/honeygain:latest" + assert result["container_name"] == "cashpilot-honeygain" + assert result["restart"] == "unless-stopped" + + def test_no_image_returns_none(self): + svc = _mock_service(image="") + svc["docker"]["image"] = "" + result = compose_generator._service_to_compose(svc) + assert result is None + + def test_no_docker_returns_none(self): + svc = {"name": "No Docker", "slug": "nodock", "docker": {}} + result = compose_generator._service_to_compose(svc) + assert result is None + + def test_env_vars_included(self): + svc = _mock_service(env=[ + {"key": "EMAIL", "default": "user@example.com"}, + {"key": "PASSWORD", "default": ""}, + ]) + result = compose_generator._service_to_compose(svc) + assert "EMAIL" in result["environment"] + assert result["environment"]["EMAIL"] == "user@example.com" + + def test_env_override(self): + svc = _mock_service(env=[{"key": "EMAIL", "default": "default@x.com"}]) + result = compose_generator._service_to_compose(svc, env_vars={"EMAIL": "custom@y.com"}) + assert result["environment"]["EMAIL"] == "custom@y.com" + + def test_hostname_substitution(self): + svc = _mock_service(env=[{"key": "DEVICE_NAME", "default": "{hostname}"}]) + result = compose_generator._service_to_compose(svc, hostname="myhost") + assert result["environment"]["DEVICE_NAME"] == "myhost" + + def test_ports_included(self): + svc = _mock_service(ports=["28967:28967/tcp"]) + result = compose_generator._service_to_compose(svc) + assert "28967:28967/tcp" in result["ports"] + + def test_volumes_included(self): + svc = _mock_service(volumes=["/data/storj:/app/config"]) + result = compose_generator._service_to_compose(svc) + assert "/data/storj:/app/config" in result["volumes"] + + def test_network_mode(self): + svc = _mock_service(network_mode="host") + result = compose_generator._service_to_compose(svc) + assert result["network_mode"] == "host" + + def test_cap_add(self): + svc = _mock_service(cap_add=["NET_ADMIN"]) + result = compose_generator._service_to_compose(svc) + assert result["cap_add"] == ["NET_ADMIN"] + + def test_command(self): + svc = _mock_service(command="/entrypoint.sh --arg") + result = compose_generator._service_to_compose(svc) + assert result["command"] == "/entrypoint.sh --arg" + + def test_required_env_placeholder(self): + svc = _mock_service(env=[{"key": "TOKEN", "default": "", "required": True, "label": "API Token"}]) + result = compose_generator._service_to_compose(svc) + assert result["environment"]["TOKEN"] == "" + + def test_labels_included(self): + svc = _mock_service() + result = compose_generator._service_to_compose(svc) + assert result["labels"]["cashpilot.managed"] == "true" + assert result["labels"]["cashpilot.service"] == "honeygain" + + +class TestGenerateComposeSingle: + def test_generates_valid_yaml(self): + svc = _mock_service() + with patch("app.compose_generator.get_service", return_value=svc): + output = compose_generator.generate_compose_single("honeygain") + assert "Generated by CashPilot" in output + # Parse YAML (skip the comment header) + lines = [l for l in output.split("\n") if not l.startswith("#")] + parsed = yaml.safe_load("\n".join(lines)) + assert "services" in parsed + + def test_unknown_service_raises(self): + with patch("app.compose_generator.get_service", return_value=None): + with pytest.raises(ValueError, match="Unknown service"): + compose_generator.generate_compose_single("nope") + + def test_no_image_raises(self): + svc = {"name": "No Image", "slug": "noimg", "docker": {}} + with patch("app.compose_generator.get_service", return_value=svc): + with pytest.raises(ValueError, match="no Docker image"): + compose_generator.generate_compose_single("noimg") + + +class TestGenerateComposeMulti: + def test_generates_multi(self): + svc1 = _mock_service("svc1", "Service 1") + svc2 = _mock_service("svc2", "Service 2", image="img2:latest") + + def mock_get(slug): + return {"svc1": svc1, "svc2": svc2}.get(slug) + + with patch("app.compose_generator.get_service", side_effect=mock_get): + output = compose_generator.generate_compose_multi(["svc1", "svc2"]) + assert "cashpilot-svc1" in output + assert "cashpilot-svc2" in output + + def test_empty_list_raises(self): + with patch("app.compose_generator.get_service", return_value=None): + with pytest.raises(ValueError, match="No deployable"): + compose_generator.generate_compose_multi(["nonexistent"]) + + +class TestGenerateComposeAll: + def test_generates_all(self): + svcs = [ + _mock_service("a", "A"), + _mock_service("b", "B", image="b:latest"), + ] + with ( + patch("app.compose_generator.get_services", return_value=svcs), + patch("app.compose_generator.get_service", side_effect=lambda s: next((x for x in svcs if x["slug"] == s), None)), + ): + output = compose_generator.generate_compose_all() + assert "cashpilot-a" in output + assert "cashpilot-b" in output diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..e27a883 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,445 @@ +"""Tests for the async SQLite database layer.""" + +import asyncio +import os +from pathlib import Path +from unittest.mock import patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest + +from app import database + + +@pytest.fixture +def db_dir(tmp_path): + """Point DB at a temporary directory.""" + db_path = tmp_path / "cashpilot.db" + with ( + patch.object(database, "DB_DIR", tmp_path), + patch.object(database, "DB_PATH", db_path), + ): + yield tmp_path + + +@pytest.fixture +def db(db_dir): + """Initialize DB and yield the directory.""" + asyncio.run(database.init_db()) + return db_dir + + +class TestInitDb: + def test_creates_tables(self, db): + async def check(): + conn = await database._get_db() + try: + cursor = await conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + tables = {row["name"] for row in await cursor.fetchall()} + assert "earnings" in tables + assert "config" in tables + assert "deployments" in tables + assert "users" in tables + assert "workers" in tables + assert "user_preferences" in tables + assert "health_events" in tables + finally: + await conn.close() + + asyncio.run(check()) + + def test_idempotent(self, db): + """Running init_db twice should not error.""" + asyncio.run(database.init_db()) + + +class TestEarnings: + def test_upsert_and_get_summary(self, db): + async def run(): + await database.upsert_earnings("honeygain", 5.50, "USD") + await database.upsert_earnings("earnapp", 3.25, "USD") + summary = await database.get_earnings_summary() + slugs = {e["platform"] for e in summary} + assert "honeygain" in slugs + assert "earnapp" in slugs + hg = next(e for e in summary if e["platform"] == "honeygain") + assert hg["balance"] == 5.50 + + asyncio.run(run()) + + def test_upsert_updates_balance(self, db): + async def run(): + await database.upsert_earnings("honeygain", 5.0, "USD", "2026-01-01") + await database.upsert_earnings("honeygain", 7.0, "USD", "2026-01-01") + summary = await database.get_earnings_summary() + hg = next(e for e in summary if e["platform"] == "honeygain") + assert hg["balance"] == 7.0 + + asyncio.run(run()) + + def test_get_earnings_history_week(self, db): + async def run(): + await database.upsert_earnings("hg", 1.0, "USD") + result = await database.get_earnings_history("week") + assert isinstance(result, list) + + asyncio.run(run()) + + def test_get_earnings_history_all(self, db): + async def run(): + await database.upsert_earnings("hg", 1.0, "USD") + result = await database.get_earnings_history("all") + assert isinstance(result, list) + + asyncio.run(run()) + + def test_get_daily_earnings(self, db): + async def run(): + await database.upsert_earnings("hg", 10.0, "USD") + result = await database.get_daily_earnings(7) + assert len(result) == 7 + for entry in result: + assert "date" in entry + assert "amount" in entry + + asyncio.run(run()) + + def test_get_earnings_per_service(self, db): + async def run(): + await database.upsert_earnings("hg", 10.0, "USD") + result = await database.get_earnings_per_service() + assert len(result) >= 1 + + asyncio.run(run()) + + def test_get_earnings_dashboard_summary(self, db): + async def run(): + await database.upsert_earnings("hg", 10.0, "USD") + summary = await database.get_earnings_dashboard_summary() + assert "total" in summary + assert "today" in summary + assert "month" in summary + assert "today_change" in summary + + asyncio.run(run()) + + +class TestConfig: + def test_set_and_get_config(self, db): + async def run(): + await database.set_config("my_key", "my_value") + result = await database.get_config("my_key") + assert result == "my_value" + + asyncio.run(run()) + + def test_get_all_config(self, db): + async def run(): + await database.set_config("k1", "v1") + await database.set_config("k2", "v2") + result = await database.get_config() + assert isinstance(result, dict) + assert result["k1"] == "v1" + assert result["k2"] == "v2" + + asyncio.run(run()) + + def test_get_missing_key_returns_none(self, db): + async def run(): + result = await database.get_config("nonexistent") + assert result is None + + asyncio.run(run()) + + def test_set_config_bulk(self, db): + async def run(): + await database.set_config_bulk({"a": "1", "b": "2"}) + cfg = await database.get_config() + assert cfg["a"] == "1" + assert cfg["b"] == "2" + + asyncio.run(run()) + + def test_delete_config_keys(self, db): + async def run(): + await database.set_config("del_me", "val") + await database.delete_config_keys(["del_me"]) + result = await database.get_config("del_me") + assert result is None + + asyncio.run(run()) + + def test_delete_empty_keys_noop(self, db): + async def run(): + await database.delete_config_keys([]) + + asyncio.run(run()) + + def test_secret_key_encrypted(self, db): + async def run(): + await database.set_config("honeygain_password", "secret123") + result = await database.get_config("honeygain_password") + assert result == "secret123" + # Verify it was actually stored encrypted + conn = await database._get_db() + try: + cursor = await conn.execute( + "SELECT value FROM config WHERE key = ?", + ("honeygain_password",), + ) + row = await cursor.fetchone() + assert row["value"].startswith("enc:") + finally: + await conn.close() + + asyncio.run(run()) + + +class TestDeployments: + def test_save_and_get_deployments(self, db): + async def run(): + await database.save_deployment("honeygain", "abc123") + deps = await database.get_deployments() + assert len(deps) == 1 + assert deps[0]["slug"] == "honeygain" + + asyncio.run(run()) + + def test_get_deployment(self, db): + async def run(): + await database.save_deployment("earnapp", "xyz789") + dep = await database.get_deployment("earnapp") + assert dep is not None + assert dep["container_id"] == "xyz789" + + asyncio.run(run()) + + def test_get_missing_deployment(self, db): + async def run(): + dep = await database.get_deployment("missing") + assert dep is None + + asyncio.run(run()) + + def test_remove_deployment(self, db): + async def run(): + await database.save_deployment("test", "cid") + await database.remove_deployment("test") + dep = await database.get_deployment("test") + assert dep is None + + asyncio.run(run()) + + def test_save_external_deployment(self, db): + async def run(): + await database.save_deployment("grass", "", status="external") + dep = await database.get_deployment("grass") + assert dep["status"] == "external" + + asyncio.run(run()) + + +class TestUsers: + def test_create_and_get_user(self, db): + async def run(): + uid = await database.create_user("alice", "hashed_pw", "owner") + assert uid > 0 + user = await database.get_user_by_username("alice") + assert user is not None + assert user["username"] == "alice" + assert user["role"] == "owner" + + asyncio.run(run()) + + def test_get_user_by_id(self, db): + async def run(): + uid = await database.create_user("bob", "hashed", "viewer") + user = await database.get_user_by_id(uid) + assert user["username"] == "bob" + + asyncio.run(run()) + + def test_get_nonexistent_user(self, db): + async def run(): + assert await database.get_user_by_username("nobody") is None + assert await database.get_user_by_id(9999) is None + + asyncio.run(run()) + + def test_has_any_users(self, db): + async def run(): + assert not await database.has_any_users() + await database.create_user("first", "pw", "owner") + assert await database.has_any_users() + + asyncio.run(run()) + + def test_list_users(self, db): + async def run(): + await database.create_user("u1", "pw", "owner") + await database.create_user("u2", "pw", "viewer") + users = await database.list_users() + assert len(users) == 2 + + asyncio.run(run()) + + def test_update_user_role(self, db): + async def run(): + uid = await database.create_user("user1", "pw", "viewer") + await database.update_user_role(uid, "writer") + user = await database.get_user_by_id(uid) + assert user["role"] == "writer" + + asyncio.run(run()) + + def test_delete_user(self, db): + async def run(): + uid = await database.create_user("del_user", "pw", "viewer") + await database.delete_user(uid) + assert await database.get_user_by_id(uid) is None + + asyncio.run(run()) + + +class TestUserPreferences: + def test_save_and_get_preferences(self, db): + async def run(): + uid = await database.create_user("pref_user", "pw", "owner") + await database.save_user_preferences(uid, "fresh", "[]", "UTC", False) + prefs = await database.get_user_preferences(uid) + assert prefs is not None + assert prefs["setup_mode"] == "fresh" + + asyncio.run(run()) + + def test_get_missing_preferences(self, db): + async def run(): + prefs = await database.get_user_preferences(9999) + assert prefs is None + + asyncio.run(run()) + + def test_mark_setup_completed(self, db): + async def run(): + uid = await database.create_user("setup_user", "pw", "owner") + await database.save_user_preferences(uid) + await database.mark_setup_completed(uid) + prefs = await database.get_user_preferences(uid) + assert prefs["setup_completed"] == 1 + + asyncio.run(run()) + + +class TestWorkers: + def test_upsert_worker(self, db): + async def run(): + wid = await database.upsert_worker("client-1", "worker-1", "http://w1:8081") + assert wid > 0 + worker = await database.get_worker(wid) + assert worker["name"] == "worker-1" + assert worker["status"] == "online" + + asyncio.run(run()) + + def test_upsert_worker_updates(self, db): + async def run(): + wid1 = await database.upsert_worker("client-1", "name1", "http://w1:8081") + wid2 = await database.upsert_worker("client-1", "name2", "http://w1:8082") + assert wid1 == wid2 + worker = await database.get_worker(wid1) + assert worker["name"] == "name2" + + asyncio.run(run()) + + def test_list_workers(self, db): + async def run(): + await database.upsert_worker("c1", "w1") + await database.upsert_worker("c2", "w2") + workers = await database.list_workers() + assert len(workers) == 2 + + asyncio.run(run()) + + def test_set_worker_status(self, db): + async def run(): + wid = await database.upsert_worker("c1", "w1") + await database.set_worker_status(wid, "offline") + worker = await database.get_worker(wid) + assert worker["status"] == "offline" + + asyncio.run(run()) + + def test_delete_worker(self, db): + async def run(): + wid = await database.upsert_worker("c1", "w1") + await database.delete_worker(wid) + assert await database.get_worker(wid) is None + + asyncio.run(run()) + + def test_get_worker_by_name(self, db): + async def run(): + await database.upsert_worker("c1", "myworker") + worker = await database.get_worker_by_name("myworker") + assert worker is not None + assert worker["name"] == "myworker" + + asyncio.run(run()) + + def test_get_missing_worker(self, db): + async def run(): + assert await database.get_worker(9999) is None + assert await database.get_worker_by_name("nope") is None + + asyncio.run(run()) + + +class TestHealthEvents: + def test_record_and_get_scores(self, db): + async def run(): + await database.record_health_event("honeygain", "check_ok") + await database.record_health_event("honeygain", "check_ok") + await database.record_health_event("honeygain", "restart") + scores = await database.get_health_scores(7) + assert len(scores) == 1 + assert scores[0]["slug"] == "honeygain" + assert scores[0]["restarts"] == 1 + assert 0 <= scores[0]["score"] <= 100 + + asyncio.run(run()) + + def test_empty_scores(self, db): + async def run(): + scores = await database.get_health_scores(7) + assert scores == [] + + asyncio.run(run()) + + +class TestDataRetention: + def test_purge_returns_count(self, db): + async def run(): + result = await database.purge_old_data() + assert result == 0 # nothing old to purge + + asyncio.run(run()) + + +class TestEncryption: + def test_encrypt_decrypt_round_trip(self): + encrypted = database.encrypt_value("secret123") + assert encrypted.startswith("enc:") + assert database.decrypt_value(encrypted) == "secret123" + + def test_decrypt_unencrypted_value(self): + assert database.decrypt_value("plaintext") == "plaintext" + + def test_is_secret_key(self): + assert database._is_secret_key("honeygain_password") + assert database._is_secret_key("grass_access_token") + assert database._is_secret_key("proxyrack_api_key") + assert not database._is_secret_key("honeygain_email") + assert not database._is_secret_key("collect_interval") diff --git a/tests/test_exchange_rates.py b/tests/test_exchange_rates.py new file mode 100644 index 0000000..0b4a8b5 --- /dev/null +++ b/tests/test_exchange_rates.py @@ -0,0 +1,65 @@ +"""Tests for exchange rate service.""" + +import os + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest + +from app import exchange_rates + + +class TestToUsd: + def test_usd_passthrough(self): + assert exchange_rates.to_usd(10.0, "USD") == 10.0 + + def test_unknown_currency_returns_none(self): + assert exchange_rates.to_usd(10.0, "UNKNOWN_XYZ") is None + + def test_crypto_conversion(self): + exchange_rates._crypto_usd["MYST"] = 0.10 + try: + result = exchange_rates.to_usd(100.0, "MYST") + assert result == pytest.approx(10.0) + finally: + exchange_rates._crypto_usd.pop("MYST", None) + + def test_fiat_conversion(self): + exchange_rates._fiat_rates["EUR"] = 0.92 + try: + result = exchange_rates.to_usd(92.0, "EUR") + assert result == pytest.approx(100.0) + finally: + exchange_rates._fiat_rates.pop("EUR", None) + + def test_fiat_zero_rate_returns_none(self): + exchange_rates._fiat_rates["ZZZ"] = 0.0 + try: + assert exchange_rates.to_usd(10.0, "ZZZ") is None + finally: + exchange_rates._fiat_rates.pop("ZZZ", None) + + +class TestGetAll: + def test_returns_structure(self): + result = exchange_rates.get_all() + assert "fiat" in result + assert "crypto_usd" in result + assert "last_updated" in result + assert isinstance(result["fiat"], dict) + assert result["fiat"]["USD"] == 1.0 + + +class TestRefresh: + @pytest.mark.asyncio + async def test_refresh_handles_network_error(self): + """Refresh should not raise even if APIs are unreachable.""" + from unittest.mock import AsyncMock, patch + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get = AsyncMock(side_effect=Exception("Network error")) + + with patch("app.exchange_rates.httpx.AsyncClient", return_value=mock_client): + await exchange_rates.refresh() diff --git a/tests/test_exchange_rates_extended.py b/tests/test_exchange_rates_extended.py new file mode 100644 index 0000000..89a2a56 --- /dev/null +++ b/tests/test_exchange_rates_extended.py @@ -0,0 +1,58 @@ +"""Extended tests for exchange_rates.py — covers the refresh() function.""" + +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +from app import exchange_rates + + +def _mock_response(status_code=200, json_data=None): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + return resp + + +class TestRefreshSuccess: + def test_refresh_fetches_rates(self): + crypto_resp = _mock_response(200, {"mysterium": {"usd": 0.10}}) + fiat_resp = _mock_response(200, {"rates": {"EUR": 0.92, "GBP": 0.79}}) + + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + client.get.side_effect = [crypto_resp, fiat_resp] + + with patch("app.exchange_rates.httpx.AsyncClient", return_value=client): + asyncio.run(exchange_rates.refresh()) + + assert exchange_rates._crypto_usd.get("MYST") == 0.10 + assert "EUR" in exchange_rates._fiat_rates + assert exchange_rates._fiat_rates["EUR"] == 0.92 + assert exchange_rates._last_fetch > 0 + + def test_refresh_crypto_failure_still_fetches_fiat(self): + crypto_resp = _mock_response(500) + fiat_resp = _mock_response(200, {"rates": {"JPY": 150.0}}) + + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + client.get.side_effect = [crypto_resp, fiat_resp] + + with patch("app.exchange_rates.httpx.AsyncClient", return_value=client): + asyncio.run(exchange_rates.refresh()) + + assert "JPY" in exchange_rates._fiat_rates + + def test_refresh_total_failure(self): + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + client.get.side_effect = Exception("Network error") + + with patch("app.exchange_rates.httpx.AsyncClient", return_value=client): + asyncio.run(exchange_rates.refresh()) # Should not raise diff --git a/tests/test_main_deploy_routes.py b/tests/test_main_deploy_routes.py new file mode 100644 index 0000000..e518da9 --- /dev/null +++ b/tests/test_main_deploy_routes.py @@ -0,0 +1,554 @@ +"""Tests for main.py deploy/stop/restart/remove routes and worker proxy commands. + +These routes proxy commands to workers via httpx, so we mock the httpx calls +and the database layer. +""" + +import asyncio +import json +import os +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import httpx +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +# No-op lifespan +@asynccontextmanager +async def _noop_lifespan(a): + yield + +app.router.lifespan_context = _noop_lifespan + + +def _owner_user(): + return {"uid": 1, "u": "admin", "r": "owner"} + + +def _writer_user(): + return {"uid": 2, "u": "writer", "r": "writer"} + + +def _auth_owner(): + return patch("app.main.auth.get_current_user", return_value=_owner_user()) + + +def _auth_writer(): + return patch("app.main.auth.get_current_user", return_value=_writer_user()) + + +def _no_auth(): + return patch("app.main.auth.get_current_user", return_value=None) + + +@pytest.fixture +def client(): + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +def _online_worker(wid=1, url="http://192.168.1.10:8081"): + return {"id": wid, "name": "w1", "status": "online", "url": url} + + +def _mock_httpx_resp(status_code=200, json_data=None): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.text = json.dumps(json_data or {}) + resp.headers = {"content-type": "application/json"} + return resp + + +# --------------------------------------------------------------------------- +# Deploy route +# --------------------------------------------------------------------------- + + +class TestApiDeploy: + def test_deploy_success(self, client): + svc = { + "slug": "honeygain", "name": "Honeygain", + "docker": { + "image": "honeygain/honeygain:latest", + "env": [{"key": "EMAIL", "default": "user@test.com"}], + "ports": ["8080:80/tcp"], + "volumes": ["/data:/app/data"], + }, + } + worker = _online_worker() + httpx_resp = _mock_httpx_resp(200, {"container_id": "abc123"}) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = httpx_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, + return_value=[worker]), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/deploy/honeygain", json={"env": {}, "hostname": "myhost"}) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "deployed" + + def test_deploy_service_not_found(self, client): + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, + return_value=[_online_worker()]), + patch("app.main.catalog.get_service", return_value=None), + ): + resp = client.post("/api/deploy/nope", json={}) + assert resp.status_code == 404 + + def test_deploy_no_image(self, client): + svc = {"slug": "grass", "name": "Grass", "docker": {}} + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, + return_value=[_online_worker()]), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.post("/api/deploy/grass", json={}) + assert resp.status_code == 400 + + def test_deploy_no_auth(self, client): + with _no_auth(): + resp = client.post("/api/deploy/honeygain", json={}) + assert resp.status_code == 401 + + +# --------------------------------------------------------------------------- +# Stop / Restart / Start / Remove (service management routes) +# --------------------------------------------------------------------------- + + +class TestServiceManagement: + def _setup_proxy(self): + """Common setup for proxy tests: single online worker, mock httpx.""" + worker = _online_worker() + httpx_resp = _mock_httpx_resp(200, {"status": "ok"}) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = httpx_resp + mock_client.delete.return_value = httpx_resp + + return worker, mock_client + + def test_restart_service(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/services/honeygain/restart") + assert resp.status_code == 200 + + def test_stop_service(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/services/honeygain/stop") + assert resp.status_code == 200 + + def test_start_service(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/services/honeygain/start") + assert resp.status_code == 200 + + def test_remove_service(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.remove_deployment", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.delete("/api/services/honeygain") + assert resp.status_code == 200 + + def test_service_logs(self, client): + worker = _online_worker() + httpx_resp = _mock_httpx_resp(200, {"logs": "log content"}) + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get.return_value = httpx_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.get("/api/services/honeygain/logs?lines=50") + assert resp.status_code == 200 + + def test_old_stop_route(self, client): + """Test the legacy /api/stop/{slug} route.""" + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/stop/honeygain") + assert resp.status_code == 200 + + def test_old_restart_route(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/restart/honeygain") + assert resp.status_code == 200 + + def test_old_remove_route(self, client): + worker, mock_client = self._setup_proxy() + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.remove_deployment", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.delete("/api/remove/honeygain") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# Worker proxy error handling +# --------------------------------------------------------------------------- + + +class TestProxyErrors: + def test_proxy_worker_error_response(self, client): + worker = _online_worker() + error_resp = _mock_httpx_resp(500, {"detail": "Docker error"}) + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = error_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/services/honeygain/restart") + assert resp.status_code == 500 + + def test_proxy_worker_httpx_error(self, client): + worker = _online_worker() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.side_effect = httpx.ConnectError("Connection refused") + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/services/honeygain/restart") + assert resp.status_code == 503 + + def test_proxy_worker_offline(self, client): + worker = {"id": 1, "name": "w1", "status": "offline", "url": "http://192.168.1.10:8081"} + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + ): + resp = client.post("/api/services/honeygain/restart?worker_id=1") + assert resp.status_code == 503 + + def test_proxy_worker_not_found(self, client): + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=None), + ): + resp = client.post("/api/services/honeygain/restart?worker_id=99") + assert resp.status_code == 404 + + def test_proxy_worker_no_url(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "url": ""} + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + ): + resp = client.post("/api/services/honeygain/restart?worker_id=1") + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# Worker command proxy route +# --------------------------------------------------------------------------- + + +class TestWorkerCommand: + def _setup(self): + worker = _online_worker() + httpx_resp = _mock_httpx_resp(200, {"status": "ok"}) + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = httpx_resp + mock_client.delete.return_value = httpx_resp + return worker, mock_client + + def test_command_deploy(self, client): + worker, mock_client = self._setup() + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "deploy", "slug": "honeygain", "spec": {"image": "test"}, + }) + assert resp.status_code == 200 + + def test_command_stop(self, client): + worker, mock_client = self._setup() + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "stop", "slug": "honeygain", + }) + assert resp.status_code == 200 + + def test_command_remove(self, client): + worker, mock_client = self._setup() + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "remove", "slug": "honeygain", + }) + assert resp.status_code == 200 + + def test_command_unknown(self, client): + worker, mock_client = self._setup() + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "nuke", "slug": "honeygain", + }) + assert resp.status_code == 400 + + def test_command_worker_offline(self, client): + worker = {"id": 1, "name": "w1", "status": "offline", "url": "http://192.168.1.10:8081"} + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "stop", "slug": "honeygain", + }) + assert resp.status_code == 503 + + def test_command_worker_not_found(self, client): + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=None), + ): + resp = client.post("/api/workers/99/command", json={ + "command": "stop", "slug": "honeygain", + }) + assert resp.status_code == 404 + + def test_command_httpx_error(self, client): + worker = _online_worker() + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.side_effect = httpx.ConnectError("refused") + + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "restart", "slug": "honeygain", + }) + assert resp.status_code == 503 + + +# --------------------------------------------------------------------------- +# Deployed services aggregation (multi-node) +# --------------------------------------------------------------------------- + + +class TestDeployedServicesAggregation: + def test_aggregation_with_workers_and_earnings(self, client): + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([ + {"slug": "honeygain", "name": "hg", "status": "running", "image": "hg:latest", "cpu_percent": 1.5, "memory_mb": 50}, + ]), + "apps": "[]", + }] + earnings = [{"platform": "honeygain", "balance": 5.0, "currency": "USD"}] + health = [{"slug": "honeygain", "score": 95, "uptime_pct": 99, "restarts": 0}] + svc = {"name": "Honeygain", "category": "bandwidth", "cashout": {"min_amount": 20}, "referral": {"signup_url": "https://r.hg.com"}, "website": "https://honeygain.com"} + + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=earnings), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=health), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.get("/api/services/deployed") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["slug"] == "honeygain" + assert data[0]["balance"] == 5.0 + assert data[0]["instances"] == 1 + assert len(data[0]["instance_details"]) == 1 + + def test_multi_node_aggregation(self, client): + workers = [ + { + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([ + {"slug": "honeygain", "name": "hg-1", "status": "running", "image": "hg:latest", "cpu_percent": 1.0, "memory_mb": 30}, + ]), + "apps": "[]", + }, + { + "id": 2, "name": "w2", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([ + {"slug": "honeygain", "name": "hg-2", "status": "running", "image": "hg:latest", "cpu_percent": 2.0, "memory_mb": 40}, + ]), + "apps": "[]", + }, + ] + svc = {"name": "Honeygain", "category": "bandwidth"} + + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.get("/api/services/deployed") + data = resp.json() + assert len(data) == 1 + assert data[0]["instances"] == 2 + assert float(data[0]["cpu"].rstrip("%")) == 3.0 + + +# --------------------------------------------------------------------------- +# Earnings summary with non-USD and bonuses +# --------------------------------------------------------------------------- + + +class TestEarningsSummaryAdvanced: + def test_earnings_summary_with_non_usd(self, client): + summary = {"total": 0.0, "today": 0.0, "month": 0.0, "today_change": 0.0} + earnings = [ + {"platform": "grass", "balance": 100.0, "currency": "GRASS"}, + ] + with ( + _auth_owner(), + patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, return_value=summary), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=earnings), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + patch("app.main.exchange_rates.to_usd", return_value=0.50), + ): + resp = client.get("/api/earnings/summary") + assert resp.status_code == 200 + + def test_earnings_summary_with_bonus(self, client): + summary = {"total": 10.0, "today": 1.0, "month": 5.0, "today_change": 0.5} + earnings = [ + {"platform": "honeygain", "balance": 15.0, "currency": "USD"}, + ] + config = {"honeygain_signup_bonus": "5.0"} + with ( + _auth_owner(), + patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, return_value=summary), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value=config), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=earnings), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/earnings/summary") + data = resp.json() + assert data["total_bonus"] == 5.0 + assert data["total_adjusted"] == 10.0 diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py new file mode 100644 index 0000000..55a353b --- /dev/null +++ b/tests/test_main_routes.py @@ -0,0 +1,1153 @@ +"""Tests for main.py FastAPI routes using TestClient. + +Covers auth routes, page routes, API endpoints for services, earnings, +config, users, fleet workers, and compose export. +""" + +import asyncio +import json +import os +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +# Replace the real lifespan with a no-op for tests +@asynccontextmanager +async def _noop_lifespan(a): + yield + + +app.router.lifespan_context = _noop_lifespan + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _owner_user(): + return {"uid": 1, "u": "admin", "r": "owner"} + + +def _writer_user(): + return {"uid": 2, "u": "writer", "r": "writer"} + + +def _viewer_user(): + return {"uid": 3, "u": "viewer", "r": "viewer"} + + +def _auth_owner(): + return patch("app.main.auth.get_current_user", return_value=_owner_user()) + + +def _auth_writer(): + return patch("app.main.auth.get_current_user", return_value=_writer_user()) + + +def _auth_viewer(): + return patch("app.main.auth.get_current_user", return_value=_viewer_user()) + + +def _no_auth(): + return patch("app.main.auth.get_current_user", return_value=None) + + +@pytest.fixture +def client(): + """TestClient with no-op lifespan to avoid scheduler/DB issues.""" + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +# --------------------------------------------------------------------------- +# Utility functions +# --------------------------------------------------------------------------- + + +class TestSafeJson: + def test_valid_json(self): + from app.main import _safe_json + assert _safe_json('{"a": 1}') == {"a": 1} + + def test_invalid_json_fallback(self): + from app.main import _safe_json + assert _safe_json("not json") == [] + + def test_invalid_json_custom_fallback(self): + from app.main import _safe_json + assert _safe_json("bad", {}) == {} + + def test_none_input(self): + from app.main import _safe_json + assert _safe_json(None) == [] + + +class TestResolveWorkerId: + def test_explicit_worker_id(self): + from app.main import _resolve_worker_id + result = asyncio.run(_resolve_worker_id(42)) + assert result == 42 + + def test_auto_resolve_single_worker(self): + from app.main import _resolve_worker_id + with patch("app.main.database.list_workers", new_callable=AsyncMock, + return_value=[{"id": 7, "status": "online"}]): + result = asyncio.run(_resolve_worker_id(None)) + assert result == 7 + + def test_no_workers_raises_503(self): + from app.main import _resolve_worker_id + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]): + with pytest.raises(Exception, match="No workers online"): + asyncio.run(_resolve_worker_id(None)) + + def test_multiple_workers_raises_400(self): + from app.main import _resolve_worker_id + workers = [ + {"id": 1, "status": "online"}, + {"id": 2, "status": "online"}, + ] + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): + with pytest.raises(Exception, match="worker_id is required"): + asyncio.run(_resolve_worker_id(None)) + + +class TestGetAllWorkerContainers: + def test_docker_containers(self): + from app.main import _get_all_worker_containers + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "honeygain", "name": "hg", "status": "running"}]), + "apps": "[]", + }] + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): + result = asyncio.run(_get_all_worker_containers()) + assert len(result) == 1 + assert result[0]["slug"] == "honeygain" + assert result[0]["deployed_by"] == "w1" + + def test_android_apps(self): + from app.main import _get_all_worker_containers + workers = [{ + "id": 2, "name": "phone", "status": "online", + "system_info": json.dumps({"device_type": "android"}), + "containers": "[]", + "apps": json.dumps([{"slug": "earnapp", "running": True}]), + }] + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): + result = asyncio.run(_get_all_worker_containers()) + assert len(result) == 1 + assert result[0]["slug"] == "earnapp" + assert result[0]["_is_android"] is True + + def test_offline_workers_skipped(self): + from app.main import _get_all_worker_containers + workers = [{"id": 1, "name": "w1", "status": "offline", "system_info": "{}", "containers": "[]", "apps": "[]"}] + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): + result = asyncio.run(_get_all_worker_containers()) + assert result == [] + + +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + + +class TestLoginRoute: + def test_login_page_redirects_to_register_if_no_users(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.get("/login", follow_redirects=False) + assert resp.status_code == 303 + assert "/register" in resp.headers["location"] + + def test_login_page_redirects_to_home_if_logged_in(self, client): + with ( + _auth_owner(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), + ): + resp = client.get("/login", follow_redirects=False) + assert resp.status_code == 303 + assert resp.headers["location"] == "/" + + def test_login_page_renders(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), + ): + resp = client.get("/login") + assert resp.status_code == 200 + + def test_login_success(self, client): + user = {"id": 1, "username": "admin", "password": "hashed", "role": "owner"} + with ( + patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value=user), + patch("app.main.auth.verify_password", return_value=True), + patch("app.main.auth.create_session_token", return_value="tok"), + patch("app.main.auth.set_session_cookie", side_effect=lambda r, t: r), + ): + resp = client.post("/login", data={"username": "admin", "password": "pass"}, follow_redirects=False) + assert resp.status_code == 303 + + def test_login_bad_password(self, client): + user = {"id": 1, "username": "admin", "password": "hashed", "role": "owner"} + with ( + patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value=user), + patch("app.main.auth.verify_password", return_value=False), + ): + resp = client.post("/login", data={"username": "admin", "password": "wrong"}) + assert resp.status_code == 401 + + def test_login_unknown_user(self, client): + with patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value=None): + resp = client.post("/login", data={"username": "nope", "password": "x"}) + assert resp.status_code == 401 + + +class TestRegisterRoute: + def test_register_page_first_user(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.get("/register") + assert resp.status_code == 200 + + def test_register_page_non_owner_redirects(self, client): + with ( + _auth_viewer(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), + ): + resp = client.get("/register", follow_redirects=False) + assert resp.status_code == 303 + + def test_register_first_user_success(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value=None), + patch("app.main.auth.hash_password", return_value="hashed"), + patch("app.main.database.create_user", new_callable=AsyncMock, return_value=1), + patch("app.main.auth.create_session_token", return_value="tok"), + patch("app.main.auth.set_session_cookie", side_effect=lambda r, t: r), + ): + resp = client.post("/register", data={ + "username": "admin", + "password": "password123", + "password_confirm": "password123", + }, follow_redirects=False) + assert resp.status_code == 303 + + def test_register_password_mismatch(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.post("/register", data={ + "username": "admin", + "password": "password123", + "password_confirm": "different", + }) + assert resp.status_code == 400 + + def test_register_password_too_short(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.post("/register", data={ + "username": "admin", + "password": "short", + "password_confirm": "short", + }) + assert resp.status_code == 400 + + def test_register_bad_username(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.post("/register", data={ + "username": "a", + "password": "password123", + "password_confirm": "password123", + }) + assert resp.status_code == 400 + + def test_register_duplicate_username(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value={"id": 1}), + ): + resp = client.post("/register", data={ + "username": "admin", + "password": "password123", + "password_confirm": "password123", + }) + assert resp.status_code == 400 + + def test_register_non_first_user_non_owner_forbidden(self, client): + with ( + _auth_viewer(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), + ): + resp = client.post("/register", data={ + "username": "new", + "password": "password123", + "password_confirm": "password123", + }) + assert resp.status_code == 403 + + +class TestLogout: + def test_logout_redirects(self, client): + with patch("app.main.auth.clear_session_cookie", side_effect=lambda r: r): + resp = client.get("/logout", follow_redirects=False) + assert resp.status_code == 303 + + +# --------------------------------------------------------------------------- +# Page routes +# --------------------------------------------------------------------------- + + +class TestPageRoutes: + def test_dashboard_not_logged_in_no_users(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), + ): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 303 + assert "/register" in resp.headers["location"] + + def test_dashboard_not_logged_in_has_users(self, client): + with ( + _no_auth(), + patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), + ): + resp = client.get("/", follow_redirects=False) + assert resp.status_code == 303 + assert "/login" in resp.headers["location"] + + def test_dashboard_logged_in(self, client): + with _auth_owner(): + resp = client.get("/") + assert resp.status_code == 200 + + def test_setup_page(self, client): + with _auth_owner(): + resp = client.get("/setup") + assert resp.status_code == 200 + + def test_setup_page_no_auth(self, client): + with _no_auth(): + resp = client.get("/setup", follow_redirects=False) + assert resp.status_code == 303 + + def test_catalog_page(self, client): + with _auth_owner(): + resp = client.get("/catalog") + assert resp.status_code == 200 + + def test_catalog_no_auth(self, client): + with _no_auth(): + resp = client.get("/catalog", follow_redirects=False) + assert resp.status_code == 303 + + def test_settings_page_owner(self, client): + with _auth_owner(): + resp = client.get("/settings") + assert resp.status_code == 200 + + def test_settings_page_non_owner(self, client): + with _auth_viewer(): + resp = client.get("/settings") + assert resp.status_code == 403 + + def test_settings_no_auth(self, client): + with _no_auth(): + resp = client.get("/settings", follow_redirects=False) + assert resp.status_code == 303 + + def test_fleet_page(self, client): + with _auth_owner(): + resp = client.get("/fleet") + assert resp.status_code == 200 + + def test_fleet_no_auth(self, client): + with _no_auth(): + resp = client.get("/fleet", follow_redirects=False) + assert resp.status_code == 303 + + def test_onboarding_page(self, client): + with _auth_owner(): + resp = client.get("/onboarding") + assert resp.status_code == 200 + + def test_onboarding_no_auth(self, client): + with _no_auth(): + resp = client.get("/onboarding", follow_redirects=False) + assert resp.status_code == 303 + + +# --------------------------------------------------------------------------- +# API: Services +# --------------------------------------------------------------------------- + + +class TestApiServices: + def test_api_mode(self, client): + with _auth_owner(): + resp = client.get("/api/mode") + assert resp.status_code == 200 + data = resp.json() + assert data["mode"] == "ui" + assert data["docker"] is False + + def test_api_mode_no_auth(self, client): + with _no_auth(): + resp = client.get("/api/mode") + assert resp.status_code == 401 + + def test_api_list_services(self, client): + with ( + _auth_owner(), + patch("app.main.catalog.get_services", return_value=[{"slug": "hg", "name": "Honeygain"}]), + ): + resp = client.get("/api/services") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + def test_api_get_service(self, client): + svc = {"slug": "hg", "name": "Honeygain", "docker": {"image": "test"}} + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/services/hg") + assert resp.status_code == 200 + assert resp.json()["slug"] == "hg" + + def test_api_get_service_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=None), + ): + resp = client.get("/api/services/nope") + assert resp.status_code == 404 + + def test_api_status(self, client): + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/status") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_api_services_available(self, client): + svcs = [{"slug": "hg", "name": "HG", "status": "active", "docker": {"image": "test"}}] + with ( + _auth_owner(), + patch("app.main.catalog.get_services", return_value=svcs), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/services/available") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["deployed"] is False + + def test_api_services_available_skips_broken(self, client): + svcs = [ + {"slug": "hg", "name": "HG", "status": "active", "docker": {"image": "test"}}, + {"slug": "dead", "name": "Dead", "status": "broken", "docker": {"image": "x"}}, + ] + with ( + _auth_owner(), + patch("app.main.catalog.get_services", return_value=svcs), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/services/available") + assert len(resp.json()) == 1 + + def test_api_services_deployed(self, client): + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/services/deployed") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_api_services_deployed_with_external(self, client): + deps = [{"slug": "grass", "status": "external"}] + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deps), + patch("app.main.catalog.get_service", return_value={"name": "Grass", "category": "bandwidth"}), + ): + resp = client.get("/api/services/deployed") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["container_status"] == "external" + + +# --------------------------------------------------------------------------- +# API: Earnings +# --------------------------------------------------------------------------- + + +class TestApiEarnings: + def test_api_earnings(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, + return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}]), + ): + resp = client.get("/api/earnings") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + def test_api_earnings_summary(self, client): + summary = {"total": 10.0, "today": 1.0, "month": 5.0, "today_change": 0.5} + with ( + _auth_owner(), + patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, return_value=summary), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/earnings/summary") + assert resp.status_code == 200 + data = resp.json() + assert "total" in data + assert "active_services" in data + + def test_api_earnings_daily(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_daily_earnings", new_callable=AsyncMock, + return_value=[{"date": "2026-01-01", "amount": 1.0}]), + ): + resp = client.get("/api/earnings/daily?days=7") + assert resp.status_code == 200 + + def test_api_earnings_daily_invalid_days(self, client): + with _auth_owner(): + resp = client.get("/api/earnings/daily?days=0") + assert resp.status_code == 400 + + def test_api_earnings_breakdown(self, client): + rows = [{"platform": "hg", "balance": 5.0, "prev_balance": 4.0, "currency": "USD", "date": "2026-01-01"}] + svc = {"name": "Honeygain", "cashout": {"min_amount": 20, "method": "paypal"}} + with ( + _auth_owner(), + patch("app.main.database.get_earnings_per_service", new_callable=AsyncMock, return_value=rows), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.get("/api/earnings/breakdown") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["delta"] == 1.0 + + def test_api_earnings_history(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_earnings_history", new_callable=AsyncMock, return_value=[]), + ): + resp = client.get("/api/earnings/history?period=week") + assert resp.status_code == 200 + + def test_api_earnings_history_invalid_period(self, client): + with _auth_owner(): + resp = client.get("/api/earnings/history?period=invalid") + assert resp.status_code == 400 + + def test_api_health_scores(self, client): + scores = [{"slug": "hg", "score": 95, "uptime_pct": 99, "restarts": 0}] + svc = {"name": "Honeygain"} + with ( + _auth_owner(), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=scores), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.get("/api/health/scores?days=7") + assert resp.status_code == 200 + + def test_api_health_scores_invalid_days(self, client): + with _auth_owner(): + resp = client.get("/api/health/scores?days=0") + assert resp.status_code == 400 + + def test_api_collect_trigger(self, client): + with _auth_writer(): + resp = client.post("/api/collect") + assert resp.status_code == 200 + assert resp.json()["status"] == "collection_started" + + def test_api_collector_alerts(self, client): + with _auth_owner(): + resp = client.get("/api/collector-alerts") + assert resp.status_code == 200 + + def test_api_exchange_rates(self, client): + rates = {"fiat": {"USD": 1.0}, "crypto_usd": {}, "last_updated": None} + with ( + _auth_owner(), + patch("app.main.exchange_rates.get_all", return_value=rates), + ): + resp = client.get("/api/exchange-rates") + assert resp.status_code == 200 + assert "fiat" in resp.json() + + +# --------------------------------------------------------------------------- +# API: Compose +# --------------------------------------------------------------------------- + + +class TestApiCompose: + def test_api_compose_single(self, client): + svc = {"slug": "hg", "name": "Honeygain", "docker": {"image": "test"}} + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.compose_generator.generate_compose_single", return_value="services:\n hg:\n image: test"), + ): + resp = client.get("/api/compose/hg") + assert resp.status_code == 200 + assert "services" in resp.text + + def test_api_compose_single_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=None), + ): + resp = client.get("/api/compose/nope") + assert resp.status_code == 404 + + def test_api_compose_multi(self, client): + with ( + _auth_owner(), + patch("app.main.compose_generator.generate_compose_multi", return_value="services:\n multi: {}"), + ): + resp = client.post("/api/compose", json={"slugs": ["a", "b"]}) + assert resp.status_code == 200 + + def test_api_compose_all(self, client): + with ( + _auth_owner(), + patch("app.main.compose_generator.generate_compose_all", return_value="services:\n all: {}"), + ): + resp = client.get("/api/compose") + assert resp.status_code == 200 + + +# --------------------------------------------------------------------------- +# API: Config +# --------------------------------------------------------------------------- + + +class TestApiConfig: + def test_api_get_config(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={"k": "v"}), + ): + resp = client.get("/api/config") + assert resp.status_code == 200 + assert resp.json() == {"k": "v"} + + def test_api_get_config_non_dict(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value=None), + ): + resp = client.get("/api/config") + assert resp.json() == {} + + def test_api_set_config(self, client): + with ( + _auth_owner(), + patch("app.main.database.set_config_bulk", new_callable=AsyncMock), + patch("app.main.catalog.get_service", return_value=None), + ): + resp = client.post("/api/config", json={"data": {"key": "value"}}) + assert resp.status_code == 200 + assert resp.json()["status"] == "saved" + + def test_api_set_config_creates_external_deployment(self, client): + svc = {"slug": "grass", "docker": {}} + with ( + _auth_owner(), + patch("app.main.database.set_config_bulk", new_callable=AsyncMock), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_deployment", new_callable=AsyncMock, return_value=None), + patch("app.main.database.save_deployment", new_callable=AsyncMock) as mock_save, + ): + resp = client.post("/api/config", json={"data": {"grass_access_token": "tok"}}) + assert resp.status_code == 200 + mock_save.assert_called_once() + + def test_api_clear_service_config(self, client): + svc = {"slug": "grass", "docker": {}} + with ( + _auth_owner(), + patch("app.main.database.delete_config_keys", new_callable=AsyncMock), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.remove_deployment", new_callable=AsyncMock), + ): + resp = client.delete("/api/config/grass") + assert resp.status_code == 200 + + def test_api_clear_config_unknown_service(self, client): + with _auth_owner(): + resp = client.delete("/api/config/nonexistent") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# API: Users +# --------------------------------------------------------------------------- + + +class TestApiUsers: + def test_api_list_users(self, client): + users = [{"id": 1, "username": "admin", "role": "owner"}] + with ( + _auth_owner(), + patch("app.main.database.list_users", new_callable=AsyncMock, return_value=users), + ): + resp = client.get("/api/users") + assert resp.status_code == 200 + assert len(resp.json()) == 1 + + def test_api_update_user_role(self, client): + user = {"id": 2, "username": "bob", "role": "viewer"} + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=user), + patch("app.main.database.update_user_role", new_callable=AsyncMock), + ): + resp = client.patch("/api/users/2", json={"role": "writer"}) + assert resp.status_code == 200 + + def test_api_update_user_invalid_role(self, client): + with _auth_owner(): + resp = client.patch("/api/users/2", json={"role": "superadmin"}) + assert resp.status_code == 400 + + def test_api_update_user_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=None), + ): + resp = client.patch("/api/users/99", json={"role": "writer"}) + assert resp.status_code == 404 + + def test_api_update_user_demote_self(self, client): + user = {"id": 1, "username": "admin", "role": "owner"} + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=user), + ): + resp = client.patch("/api/users/1", json={"role": "viewer"}) + assert resp.status_code == 400 + + def test_api_update_user_demote_last_owner(self, client): + user = {"id": 2, "username": "admin2", "role": "owner"} + all_users = [{"id": 2, "username": "admin2", "role": "owner"}] + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=user), + patch("app.main.database.list_users", new_callable=AsyncMock, return_value=all_users), + ): + resp = client.patch("/api/users/2", json={"role": "viewer"}) + assert resp.status_code == 400 + + def test_api_delete_user(self, client): + user = {"id": 2, "username": "bob", "role": "viewer"} + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=user), + patch("app.main.database.delete_user", new_callable=AsyncMock), + ): + resp = client.delete("/api/users/2") + assert resp.status_code == 200 + + def test_api_delete_user_self(self, client): + with _auth_owner(): + resp = client.delete("/api/users/1") + assert resp.status_code == 400 + + def test_api_delete_user_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_user_by_id", new_callable=AsyncMock, return_value=None), + ): + resp = client.delete("/api/users/99") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# API: Preferences +# --------------------------------------------------------------------------- + + +class TestApiPreferences: + def test_api_get_preferences(self, client): + prefs = {"setup_mode": "fresh", "selected_categories": "[]", "timezone": "UTC", "setup_completed": False} + with ( + _auth_owner(), + patch("app.main.database.get_user_preferences", new_callable=AsyncMock, return_value=prefs), + ): + resp = client.get("/api/preferences") + assert resp.status_code == 200 + assert resp.json()["setup_mode"] == "fresh" + + def test_api_get_preferences_default(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_user_preferences", new_callable=AsyncMock, return_value=None), + ): + resp = client.get("/api/preferences") + assert resp.status_code == 200 + assert resp.json()["setup_mode"] == "fresh" + + def test_api_set_preferences(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_user_preferences", new_callable=AsyncMock, return_value=None), + patch("app.main.database.save_user_preferences", new_callable=AsyncMock), + ): + resp = client.post("/api/preferences", json={"setup_mode": "monitoring"}) + assert resp.status_code == 200 + + def test_api_set_preferences_invalid_mode(self, client): + with _auth_owner(): + resp = client.post("/api/preferences", json={"setup_mode": "invalid"}) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# API: Env Info +# --------------------------------------------------------------------------- + + +class TestApiEnvInfo: + def test_api_env_info(self, client): + with _auth_owner(): + resp = client.get("/api/env-info") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + keys = {e["key"] for e in data} + assert "CASHPILOT_API_KEY" in keys + + def test_api_env_info_non_owner(self, client): + with _auth_viewer(): + resp = client.get("/api/env-info") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# API: Fleet / Workers +# --------------------------------------------------------------------------- + + +class TestApiFleet: + def test_api_worker_heartbeat(self, client): + with ( + patch("app.main.FLEET_API_KEY", "test-fleet-key"), + patch("app.main.database.upsert_worker", new_callable=AsyncMock, return_value=1), + ): + resp = client.post( + "/api/workers/heartbeat", + json={"name": "worker-1", "client_id": "c1"}, + headers={"Authorization": "Bearer test-fleet-key"}, + ) + assert resp.status_code == 200 + assert resp.json()["worker_id"] == 1 + + def test_api_worker_heartbeat_bad_key(self, client): + with patch("app.main.FLEET_API_KEY", "test-fleet-key"): + resp = client.post( + "/api/workers/heartbeat", + json={"name": "worker-1"}, + headers={"Authorization": "Bearer wrong-key"}, + ) + assert resp.status_code == 401 + + def test_api_worker_heartbeat_no_fleet_key(self, client): + with patch("app.main.FLEET_API_KEY", ""): + resp = client.post( + "/api/workers/heartbeat", + json={"name": "worker-1"}, + ) + assert resp.status_code == 503 + + def test_api_list_workers(self, client): + workers = [{"id": 1, "name": "w1", "status": "online", "containers": "[]", "apps": "[]", "system_info": "{}"}] + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + ): + resp = client.get("/api/workers") + assert resp.status_code == 200 + + def test_api_get_worker(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "containers": "[]", "apps": "[]", "system_info": "{}"} + with ( + _auth_owner(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + ): + resp = client.get("/api/workers/1") + assert resp.status_code == 200 + + def test_api_get_worker_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=None), + ): + resp = client.get("/api/workers/99") + assert resp.status_code == 404 + + def test_api_delete_worker(self, client): + worker = {"id": 1, "name": "w1", "status": "online"} + with ( + _auth_owner(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.delete_worker", new_callable=AsyncMock), + ): + resp = client.delete("/api/workers/1") + assert resp.status_code == 200 + + def test_api_delete_worker_not_found(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=None), + ): + resp = client.delete("/api/workers/99") + assert resp.status_code == 404 + + def test_api_fleet_summary(self, client): + workers = [ + {"id": 1, "name": "w1", "status": "online", "containers": "[]", "apps": "[]", "system_info": "{}"}, + {"id": 2, "name": "w2", "status": "offline", "containers": "[]", "apps": "[]", "system_info": "{}"}, + ] + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + ): + resp = client.get("/api/fleet/summary") + assert resp.status_code == 200 + data = resp.json() + assert data["total_workers"] == 2 + assert data["online_workers"] == 1 + + def test_api_fleet_api_key(self, client): + with ( + _auth_owner(), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.get("/api/fleet/api-key") + assert resp.status_code == 200 + assert resp.json()["api_key"] == "test-key" + + def test_api_fleet_api_key_non_owner(self, client): + with _auth_viewer(): + resp = client.get("/api/fleet/api-key") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# API: Worker URL validation +# --------------------------------------------------------------------------- + + +class TestValidateWorkerUrl: + def test_valid_url(self): + from app.main import _validate_worker_url + assert _validate_worker_url("http://192.168.1.10:8081") == "http://192.168.1.10:8081" + + def test_trailing_slash_stripped(self): + from app.main import _validate_worker_url + assert _validate_worker_url("http://host:8081/") == "http://host:8081" + + def test_invalid_scheme(self): + from app.main import _validate_worker_url + with pytest.raises(Exception, match="Invalid worker URL scheme"): + _validate_worker_url("ftp://host:21") + + def test_no_host(self): + from app.main import _validate_worker_url + with pytest.raises(Exception): + _validate_worker_url("http://") + + def test_loopback_blocked(self): + from app.main import _validate_worker_url + with pytest.raises(Exception, match="loopback"): + _validate_worker_url("http://127.0.0.1:8081") + + def test_localhost_blocked(self): + from app.main import _validate_worker_url + with pytest.raises(Exception, match="localhost"): + _validate_worker_url("http://localhost:8081") + + def test_tailscale_dns_allowed(self): + from app.main import _validate_worker_url + result = _validate_worker_url("http://worker.mango.ts.net:8081") + assert "worker.mango.ts.net" in result + + +# --------------------------------------------------------------------------- +# Periodic tasks +# --------------------------------------------------------------------------- + + +class TestPeriodicTasks: + def test_run_health_check(self): + from app.main import _run_health_check + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + ): + asyncio.run(_run_health_check()) + + def test_run_health_check_error(self): + from app.main import _run_health_check + with patch("app.main.database.list_workers", new_callable=AsyncMock, side_effect=Exception("db error")): + asyncio.run(_run_health_check()) # Should not raise + + def test_run_data_retention(self): + from app.main import _run_data_retention + with patch("app.main.database.purge_old_data", new_callable=AsyncMock, return_value=5): + asyncio.run(_run_data_retention()) + + def test_run_data_retention_error(self): + from app.main import _run_data_retention + with patch("app.main.database.purge_old_data", new_callable=AsyncMock, side_effect=Exception("err")): + asyncio.run(_run_data_retention()) # Should not raise + + def test_check_stale_workers(self): + from app.main import _check_stale_workers + from datetime import UTC, datetime, timedelta + old_time = (datetime.now(UTC) - timedelta(seconds=300)).isoformat() + workers = [{"id": 1, "name": "w1", "status": "online", "last_heartbeat": old_time}] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.set_worker_status", new_callable=AsyncMock) as mock_set, + ): + asyncio.run(_check_stale_workers()) + mock_set.assert_called_once_with(1, "offline") + + def test_check_stale_workers_error(self): + from app.main import _check_stale_workers + with patch("app.main.database.list_workers", new_callable=AsyncMock, side_effect=Exception("err")): + asyncio.run(_check_stale_workers()) # Should not raise + + def test_run_collection(self): + from app.main import _run_collection + from app.collectors.base import EarningsResult + mock_collector = AsyncMock() + mock_collector.collect.return_value = EarningsResult(platform="test", balance=5.0, currency="USD") + with ( + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[{"slug": "test"}]), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.collectors.make_collectors", return_value=[mock_collector]), + patch("app.main.database.upsert_earnings", new_callable=AsyncMock), + ): + asyncio.run(_run_collection()) + + def test_run_collection_with_error(self): + from app.main import _run_collection + from app.collectors.base import EarningsResult + mock_collector = AsyncMock() + mock_collector.collect.return_value = EarningsResult(platform="test", balance=0.0, error="API failed") + with ( + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[{"slug": "test"}]), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.collectors.make_collectors", return_value=[mock_collector]), + ): + asyncio.run(_run_collection()) + + +# --------------------------------------------------------------------------- +# Security middleware +# --------------------------------------------------------------------------- + + +class TestSecurityHeaders: + def test_security_headers_present(self, client): + with _auth_owner(): + resp = client.get("/api/mode") + assert resp.headers.get("X-Content-Type-Options") == "nosniff" + assert resp.headers.get("X-Frame-Options") == "DENY" + + +# --------------------------------------------------------------------------- +# API: Collectors Meta +# --------------------------------------------------------------------------- + + +class TestApiCollectorsMeta: + def test_api_collectors_meta(self, client): + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value={"name": "Test"}), + ): + resp = client.get("/api/collectors/meta") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) > 0 + # Each entry should have slug, name, fields + assert "slug" in data[0] + assert "fields" in data[0] + + def test_api_collectors_meta_non_owner(self, client): + with _auth_viewer(): + resp = client.get("/api/collectors/meta") + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# API: Per-node earnings +# --------------------------------------------------------------------------- + + +class TestApiPerNodeEarnings: + def test_per_node_earnings_unknown_slug(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + ): + resp = client.get("/api/services/honeygain/per-node-earnings") + assert resp.status_code == 200 + assert resp.json() == [] From bdfe8801728cf2de5e0d395923c3077a0cd98f10 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:27:19 +0200 Subject: [PATCH 2/5] test: add targeted tests and refine codecov config for 90%+ coverage Add 55 tests covering specific uncovered lines across catalog, collectors, database, auth, fleet_key, and main modules. Update codecov.yml with full project/patch targets, ignore patterns, and comment layout. --- codecov.yml | 17 + tests/test_coverage_gaps.py | 1035 +++++++++++++++++++++++++++++++++++ 2 files changed, 1052 insertions(+) create mode 100644 tests/test_coverage_gaps.py diff --git a/codecov.yml b/codecov.yml index 795904e..ba9fe66 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,4 +1,10 @@ +codecov: + require_ci_to_pass: true + coverage: + precision: 2 + round: down + range: "70...100" status: project: default: @@ -7,7 +13,18 @@ coverage: patch: default: target: 90% + threshold: 5% + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: true + require_base: false + require_head: true ignore: - "app/orchestrator.py" - "app/worker_api.py" + - "**/*_test.py" + - "**/tests/**" + - "**/conftest.py" diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py new file mode 100644 index 0000000..47177b5 --- /dev/null +++ b/tests/test_coverage_gaps.py @@ -0,0 +1,1035 @@ +"""Tests targeting specific uncovered lines to reach 90%+ coverage. + +Covers gaps in catalog.py, collectors/__init__.py, bytelixir.py, +main.py, fleet_key.py, database.py, and various collector edge cases. +""" + +import asyncio +import json +import os +from contextlib import asynccontextmanager +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") + +import httpx +import pytest +import yaml + +from app import catalog, database +from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP, make_collectors +from app.collectors.base import BaseCollector, EarningsResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_async_client(): + client = AsyncMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + return client + + +def _mock_response(status_code=200, json_data=None, text="", url="https://example.com"): + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data or {} + resp.text = text + resp.url = url + resp.headers = {} + resp.raise_for_status = MagicMock() + if status_code >= 400: + resp.raise_for_status.side_effect = httpx.HTTPStatusError( + f"HTTP {status_code}", request=MagicMock(), response=resp + ) + return resp + + +def _make_service_yaml(slug="test-svc", name="Test Service", category="bandwidth", + status="active", description="A test service", + docker=None): + data = { + "name": name, + "slug": slug, + "category": category, + "status": status, + "description": description, + "docker": docker or {"image": "test/image:latest"}, + } + return yaml.dump(data) + + +# --------------------------------------------------------------------------- +# catalog.py — .yaml extension with validation errors, SIGHUP, lazy init +# --------------------------------------------------------------------------- + + +class TestCatalogYamlExtension: + """Cover lines 70-81: .yaml extension parsing with validation errors.""" + + def test_yaml_extension_invalid_yaml(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "bad.yaml").write_text("{{{{invalid") + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_yaml_extension_non_dict(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "list.yaml").write_text("- item1\n- item2\n") + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_yaml_extension_missing_fields(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "incomplete.yaml").write_text(yaml.dump({"name": "Only Name"})) + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + def test_yaml_extension_underscore_skipped(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "_schema.yaml").write_text(_make_service_yaml("schema")) + (svc_dir / "good.yml").write_text(_make_service_yaml("good")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog._load_from_disk() + assert len(services) == 1 + + +class TestCatalogLazyInit: + """Cover lines 99, 115: get_services and get_service lazy loading.""" + + def test_get_services_lazy_loads(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "lazy.yml").write_text(_make_service_yaml("lazy")) + + # Clear cache + catalog._services.clear() + catalog._by_slug.clear() + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + services = catalog.get_services() + assert len(services) >= 1 + + def test_get_service_lazy_loads(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "lazysvc.yml").write_text(_make_service_yaml("lazysvc")) + + # Clear cache + catalog._services.clear() + catalog._by_slug.clear() + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + svc = catalog.get_service("lazysvc") + assert svc is not None + assert svc["slug"] == "lazysvc" + + +class TestCatalogSighup: + """Cover lines 121-128: SIGHUP handler and registration.""" + + def test_sighup_handler_reloads(self, tmp_path): + svc_dir = tmp_path / "services" / "bandwidth" + svc_dir.mkdir(parents=True) + (svc_dir / "svc.yml").write_text(_make_service_yaml("svc")) + + with patch.object(catalog, "SERVICES_DIR", tmp_path / "services"): + catalog._sighup_handler(0, None) + # After reload, service should be in cache + assert len(catalog._services) >= 1 + + def test_register_sighup_on_unix(self): + import signal + import sys + if sys.platform == "win32": + pytest.skip("Unix only") + with patch("signal.signal") as mock_signal: + catalog.register_sighup() + mock_signal.assert_called_once_with(signal.SIGHUP, catalog._sighup_handler) + + +# --------------------------------------------------------------------------- +# collectors/__init__.py — make_collectors edge cases +# --------------------------------------------------------------------------- + + +class TestMakeCollectorsEdgeCases: + """Cover lines 86, 101, 106-117: missing keys, unknown slug, instantiation error.""" + + def test_skips_unknown_slug(self): + deployments = [{"slug": "nonexistent-service"}] + collectors = make_collectors(deployments, {}) + assert len(collectors) == 0 + + def test_skips_missing_required_keys(self): + deployments = [{"slug": "honeygain"}] + # honeygain needs email + password, not providing them + collectors = make_collectors(deployments, {}) + assert len(collectors) == 0 + + def test_handles_instantiation_error(self): + """Cover lines 116-117: exception during cls(**kwargs).""" + deployments = [{"slug": "honeygain"}] + config = {"honeygain_email": "test@test.com", "honeygain_password": "pass"} + + with patch.dict(COLLECTOR_MAP, {"honeygain": MagicMock(side_effect=Exception("init error"))}): + collectors = make_collectors(deployments, config) + assert len(collectors) == 0 + + def test_optional_args_not_required(self): + """Storj with no api_url should still create a collector.""" + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, {}) + assert len(collectors) == 1 + + def test_optional_arg_value_passed(self): + deployments = [{"slug": "storj"}] + collectors = make_collectors(deployments, {"storj_api_url": "http://custom:14002"}) + assert len(collectors) == 1 + assert collectors[0].api_url == "http://custom:14002" + + def test_deployment_without_slug_key(self): + deployments = [{"other_key": "value"}] + collectors = make_collectors(deployments, {}) + assert len(collectors) == 0 + + +# --------------------------------------------------------------------------- +# bytelixir.py — _make_client, parse balance edge cases +# --------------------------------------------------------------------------- + + +class TestBytelixirMakeClient: + """Cover lines 59-77: _make_client with remember_web and xsrf_token.""" + + def test_make_client_with_all_cookies(self): + from app.collectors.bytelixir import BytelixirCollector + + c = BytelixirCollector( + session_cookie="sess-val", + remember_web="remember-val", + xsrf_token="xsrf-val", + ) + client = c._make_client() + assert isinstance(client, httpx.AsyncClient) + # Verify cookies are set + cookies = dict(client.cookies) + assert "bytelixir_session" in cookies + assert c._REMEMBER_COOKIE in cookies + assert "XSRF-TOKEN" in cookies + asyncio.run(client.aclose()) + + def test_make_client_session_only(self): + from app.collectors.bytelixir import BytelixirCollector + + c = BytelixirCollector(session_cookie="sess-only") + client = c._make_client() + cookies = dict(client.cookies) + assert "bytelixir_session" in cookies + assert c._REMEMBER_COOKIE not in cookies + assert "XSRF-TOKEN" not in cookies + asyncio.run(client.aclose()) + + +class TestBytelixirParseBalanceEdgeCases: + """Cover lines 115-123: parse_balance when all matches are zero.""" + + def test_parse_balance_all_zero(self): + from app.collectors.bytelixir import BytelixirCollector + + html = '$0.00000' + result = BytelixirCollector._parse_balance_from_html(html) + # All zero — should return first match (0.00000) + assert result == 0.0 + + def test_parse_balance_first_zero_second_nonzero(self): + from app.collectors.bytelixir import BytelixirCollector + + html = ( + '$0.00000' + '$1.23456' + ) + result = BytelixirCollector._parse_balance_from_html(html) + assert result == 1.23456 + + +# --------------------------------------------------------------------------- +# main.py — uncovered edge cases +# --------------------------------------------------------------------------- + + +# No-op lifespan for TestClient +@asynccontextmanager +async def _noop_lifespan(a): + yield + + +def _owner_user(): + return {"uid": 1, "u": "admin", "r": "owner"} + + +def _writer_user(): + return {"uid": 2, "u": "writer", "r": "writer"} + + +def _auth_owner(): + return patch("app.main.auth.get_current_user", return_value=_owner_user()) + + +def _auth_writer(): + return patch("app.main.auth.get_current_user", return_value=_writer_user()) + + +def _no_auth(): + return patch("app.main.auth.get_current_user", return_value=None) + + +@pytest.fixture +def client(): + from app.main import app + app.router.lifespan_context = _noop_lifespan + from fastapi.testclient import TestClient + with TestClient(app, raise_server_exceptions=False) as c: + yield c + + +class TestMainHealthCheckWithContainers: + """Cover lines 147-155: health check with running and non-running containers.""" + + def test_health_check_records_events(self): + from app.main import _run_health_check + + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([ + {"slug": "honeygain", "name": "hg", "status": "running"}, + {"slug": "earnapp", "name": "ea", "status": "stopped"}, + ]), + "apps": "[]", + }] + mock_record = AsyncMock() + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.record_health_event", mock_record), + ): + asyncio.run(_run_health_check()) + + # Should record check_ok for honeygain (running) and check_down for earnapp (stopped) + calls = mock_record.call_args_list + slugs_events = [(c.args[0], c.args[1]) for c in calls] + assert ("honeygain", "check_ok") in slugs_events + assert ("earnapp", "check_down") in slugs_events + + +class TestMainStaleWorkers: + """Cover _check_stale_workers with no heartbeat field.""" + + def test_stale_worker_no_heartbeat(self): + from app.main import _check_stale_workers + + workers = [{"id": 1, "name": "w1", "status": "online", "last_heartbeat": None}] + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.set_worker_status", new_callable=AsyncMock) as mock_set, + ): + asyncio.run(_check_stale_workers()) + # No heartbeat means no comparison, should not crash or mark offline + mock_set.assert_not_called() + + +class TestMainRunCollectionException: + """Cover line 167: _run_collection total failure.""" + + def test_run_collection_total_exception(self): + from app.main import _run_collection + + with patch("app.main.database.get_deployments", new_callable=AsyncMock, + side_effect=Exception("DB down")): + asyncio.run(_run_collection()) # Should not raise + + +class TestMainDeployCommandEdgeCases: + """Cover deploy route command substitution and volume substitution.""" + + def test_deploy_with_command_substitution(self, client): + svc = { + "slug": "test-cmd", "name": "TestCmd", + "docker": { + "image": "test:latest", + "env": [{"key": "TOKEN", "default": "abc"}], + "ports": [], + "volumes": ["${TOKEN}:/data:ro"], + "command": "run --token=${TOKEN}", + }, + } + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + httpx_resp = MagicMock() + httpx_resp.status_code = 200 + httpx_resp.json.return_value = {"container_id": "abc"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = httpx_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/deploy/test-cmd", json={"env": {"TOKEN": "mytoken"}}) + assert resp.status_code == 200 + + def test_deploy_with_network_mode_and_cap_add(self, client): + svc = { + "slug": "test-net", "name": "TestNet", + "docker": { + "image": "test:latest", + "env": [], + "ports": [], + "volumes": [], + "network_mode": "host", + "cap_add": ["NET_ADMIN"], + "privileged": True, + }, + } + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + httpx_resp = MagicMock() + httpx_resp.status_code = 200 + httpx_resp.json.return_value = {"container_id": "xyz"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = httpx_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.database.save_deployment", new_callable=AsyncMock), + patch("app.main.database.record_health_event", new_callable=AsyncMock), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/deploy/test-net", json={}) + assert resp.status_code == 200 + + +class TestMainProxyWorkerDeployError: + """Cover lines 940-948: proxy deploy error responses.""" + + def test_proxy_deploy_error_response(self, client): + svc = { + "slug": "hg", "name": "Honeygain", + "docker": {"image": "hg:latest", "env": [], "ports": [], "volumes": []}, + } + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + error_resp = MagicMock() + error_resp.status_code = 500 + error_resp.json.return_value = {"detail": "Docker error"} + error_resp.text = '{"detail": "Docker error"}' + error_resp.headers = {"content-type": "application/json"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = error_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/deploy/hg", json={}) + assert resp.status_code == 500 + + def test_proxy_deploy_httpx_error(self, client): + svc = { + "slug": "hg", "name": "Honeygain", + "docker": {"image": "hg:latest", "env": [], "ports": [], "volumes": []}, + } + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.side_effect = httpx.ConnectError("Connection refused") + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/deploy/hg", json={}) + assert resp.status_code == 503 + + +class TestMainProxyLogsError: + """Cover lines 964-972: proxy logs error responses.""" + + def test_proxy_logs_error_response(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + error_resp = MagicMock() + error_resp.status_code = 500 + error_resp.json.return_value = {"detail": "error"} + error_resp.text = '{"detail": "error"}' + error_resp.headers = {"content-type": "application/json"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get.return_value = error_resp + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.get("/api/services/honeygain/logs?worker_id=1") + assert resp.status_code == 500 + + def test_proxy_logs_httpx_error(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.get.side_effect = httpx.ConnectError("refused") + + with ( + _auth_writer(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.get("/api/services/honeygain/logs?worker_id=1") + assert resp.status_code == 503 + + +class TestMainComposeSingleError: + """Cover lines 1039-1040: compose single ValueError.""" + + def test_compose_single_value_error(self, client): + svc = {"slug": "bad", "name": "Bad", "docker": {}} + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.compose_generator.generate_compose_single", + side_effect=ValueError("no image")), + ): + resp = client.get("/api/compose/bad") + assert resp.status_code == 400 + + def test_compose_multi_value_error(self, client): + with ( + _auth_owner(), + patch("app.main.compose_generator.generate_compose_multi", + side_effect=ValueError("no services")), + ): + resp = client.post("/api/compose", json={"slugs": ["bad"]}) + assert resp.status_code == 400 + + def test_compose_all_value_error(self, client): + with ( + _auth_owner(), + patch("app.main.compose_generator.generate_compose_all", + side_effect=ValueError("no services")), + ): + resp = client.get("/api/compose") + assert resp.status_code == 400 + + +class TestMainPerNodeEarnings: + """Cover lines 1238-1247: per-node earnings for mysterium.""" + + def test_per_node_earnings_mysterium(self, client): + mock_collector = MagicMock() + mock_collector.get_per_node_earnings = AsyncMock(return_value=[ + {"identity": "0xabc", "earnings_myst": 5.0} + ]) + with ( + _auth_owner(), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={ + "mysterium_email": "test@test.com", + "mysterium_password": "pass", + }), + patch("app.collectors.mystnodes.MystNodesCollector", return_value=mock_collector), + ): + resp = client.get("/api/services/mysterium/per-node-earnings") + assert resp.status_code == 200 + + +class TestMainSetConfigExternalDeploySkip: + """Cover lines 1456, 1460: config set that skips services with images.""" + + def test_set_config_skips_docker_service(self, client): + """When all required keys are provided for a docker-image service, don't auto-deploy.""" + svc = {"slug": "honeygain", "docker": {"image": "hg:latest"}} + with ( + _auth_owner(), + patch("app.main.database.set_config_bulk", new_callable=AsyncMock), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_deployment", new_callable=AsyncMock) as mock_dep, + patch("app.main.database.save_deployment", new_callable=AsyncMock) as mock_save, + ): + resp = client.post("/api/config", json={"data": { + "honeygain_email": "test@test.com", + "honeygain_password": "pass", + }}) + assert resp.status_code == 200 + mock_save.assert_not_called() + + +class TestMainClearConfigWithDockerService: + """Cover main.py clear config for docker-based service.""" + + def test_clear_config_docker_service_no_deployment_removed(self, client): + svc = {"slug": "honeygain", "docker": {"image": "hg:latest"}} + with ( + _auth_owner(), + patch("app.main.database.delete_config_keys", new_callable=AsyncMock), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.remove_deployment", new_callable=AsyncMock) as mock_rm, + ): + resp = client.delete("/api/config/honeygain") + assert resp.status_code == 200 + # Docker services don't auto-create external deployments, so no removal + mock_rm.assert_not_called() + + +class TestMainPreferencesSetupCompleted: + """Cover line 1294: setup_completed triggers collection.""" + + def test_set_preferences_completed_triggers_collection(self, client): + with ( + _auth_owner(), + patch("app.main.database.get_user_preferences", new_callable=AsyncMock, return_value=None), + patch("app.main.database.save_user_preferences", new_callable=AsyncMock), + ): + resp = client.post("/api/preferences", json={"setup_completed": True}) + assert resp.status_code == 200 + + +class TestMainWorkerCommandNoUrl: + """Cover line 1659: worker command with no URL.""" + + def test_worker_command_no_url(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "url": ""} + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "stop", "slug": "honeygain", + }) + assert resp.status_code == 503 + + +class TestMainWorkerCommandHttpError: + """Cover line 1688: worker command httpx error on deploy.""" + + def test_worker_command_deploy_error(self, client): + worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} + error_resp = MagicMock() + error_resp.status_code = 500 + error_resp.text = "Internal Server Error" + error_resp.headers = {"content-type": "text/plain"} + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post.return_value = error_resp + + with ( + _auth_writer(), + patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), + patch("app.main.httpx.AsyncClient", return_value=mock_client), + patch("app.main.FLEET_API_KEY", "test-key"), + ): + resp = client.post("/api/workers/1/command", json={ + "command": "deploy", "slug": "honeygain", "spec": {"image": "test"}, + }) + assert resp.status_code == 500 + + +class TestMainEarningsSummaryWithWorkerException: + """Cover lines 1124-1125: worker container exception in summary.""" + + def test_earnings_summary_worker_exception(self, client): + summary = {"total": 10.0, "today": 1.0, "month": 5.0, "today_change": 0.5} + with ( + _auth_owner(), + patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, + return_value=summary), + patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), + patch("app.main._get_all_worker_containers", new_callable=AsyncMock, + side_effect=Exception("worker error")), + ): + resp = client.get("/api/earnings/summary") + assert resp.status_code == 200 + assert resp.json()["active_services"] == 0 + + +class TestMainServicesDeployedMultiStatus: + """Cover lines 605, 626-627: deployed services with various statuses.""" + + def test_deployed_services_with_cashout_and_referral(self, client): + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([ + {"slug": "hg", "name": "hg", "status": "restarting", "image": "hg:latest", + "cpu_percent": 0.5, "memory_mb": 25}, + {"slug": "hg", "name": "hg-2", "status": "running", "image": "hg:latest", + "cpu_percent": 1.0, "memory_mb": 30}, + ]), + "apps": "[]", + }] + svc = { + "name": "Honeygain", "category": "bandwidth", + "cashout": {"min_amount": 20}, + "referral": {"signup_url": "https://r.hg.com"}, + "website": "https://honeygain.com", + } + + with ( + _auth_owner(), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, + return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}]), + patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.catalog.get_service", return_value=svc), + ): + resp = client.get("/api/services/deployed") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["instances"] == 2 + # Best status should be "running" (higher priority than restarting) + assert data[0]["container_status"] == "running" + + +class TestMainServicesAvailableNodeCounts: + """Cover lines 713-719: node counts from worker containers.""" + + def test_services_available_with_node_counts(self, client): + svcs = [{"slug": "hg", "name": "HG", "status": "active", "docker": {"image": "test"}}] + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), + "apps": "[]", + }] + deps = [{"slug": "hg"}] + with ( + _auth_owner(), + patch("app.main.catalog.get_services", return_value=svcs), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=deps), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + ): + resp = client.get("/api/services/available") + data = resp.json() + assert len(data) == 1 + assert data[0]["deployed"] is True + assert data[0]["node_count"] == 1 + + +class TestMainGetServiceEnriched: + """Cover lines 749-750: service enrichment with worker data.""" + + def test_get_service_with_worker_data(self, client): + svc = {"slug": "hg", "name": "HG", "docker": {"image": "test"}} + workers = [{ + "id": 1, "name": "w1", "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), + "apps": "[]", + }] + with ( + _auth_owner(), + patch("app.main.catalog.get_service", return_value=svc), + patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + ): + resp = client.get("/api/services/hg") + assert resp.status_code == 200 + data = resp.json() + assert data["deployed"] is True + assert data["node_count"] == 1 + + +# --------------------------------------------------------------------------- +# Collector edge cases — small gaps +# --------------------------------------------------------------------------- + + +class TestCollectorSmallGaps: + """Cover remaining 1-3 line gaps in various collectors.""" + + def test_honeygain_login_no_token(self): + """Cover honeygain.py line 44: login response missing access_token.""" + from app.collectors.honeygain import HoneygainCollector + + login_resp = _mock_response(200, {"data": {}}) + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.honeygain.httpx.AsyncClient", return_value=client): + c = HoneygainCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is not None + + def test_iproyal_login_success_but_no_balance_field(self): + """Cover iproyal.py lines 53-54: balance response without balance field.""" + from app.collectors.iproyal import IPRoyalCollector + + login_resp = _mock_response(200, {"access_token": "tok"}) + balance_resp = _mock_response(200, {}) # missing balance field + + client = _make_async_client() + client.post.return_value = login_resp + client.get.return_value = balance_resp + + with patch("app.collectors.iproyal.httpx.AsyncClient", return_value=client): + c = IPRoyalCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + # Should either succeed with 0 or have an error + assert isinstance(result, EarningsResult) + + def test_earnfm_login_response_missing_token(self): + """Cover earnfm.py lines 53, 59: login without access_token.""" + from app.collectors.earnfm import EarnFMCollector + + login_resp = _mock_response(200, {}) # missing access_token + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.earnfm.httpx.AsyncClient", return_value=client): + c = EarnFMCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_bitping_login_missing_cookie(self): + """Cover bitping.py lines 45-48: login without cookie set.""" + from app.collectors.bitping import BitpingCollector + + login_resp = _mock_response(200, {}) + client = _make_async_client() + client.cookies = MagicMock() + client.cookies.items.return_value = [] # no token cookie + client.post.return_value = login_resp + + with patch("app.collectors.bitping.httpx.AsyncClient", return_value=client): + c = BitpingCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert result.error is not None + + def test_traffmonetizer_login_missing_token(self): + """Cover traffmonetizer.py line 45: login response without token.""" + from app.collectors.traffmonetizer import TraffmonetizerCollector + + login_resp = _mock_response(200, {"data": {}}) + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.traffmonetizer.httpx.AsyncClient", return_value=client): + c = TraffmonetizerCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_repocket_login_missing_token(self): + """Cover repocket.py lines 49, 55: login without idToken.""" + from app.collectors.repocket import RepocketCollector + + login_resp = _mock_response(200, {}) # missing idToken + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.repocket.httpx.AsyncClient", return_value=client): + c = RepocketCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_mystnodes_login_missing_token(self): + """Cover mystnodes.py lines 51, 57: login without accessToken.""" + from app.collectors.mystnodes import MystNodesCollector + + login_resp = _mock_response(200, {}) # missing accessToken + client = _make_async_client() + client.post.return_value = login_resp + + with patch("app.collectors.mystnodes.httpx.AsyncClient", return_value=client): + c = MystNodesCollector(email="test@test.com", password="pass") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_storj_no_current_month(self): + """Cover storj.py line 54: response without any known fields.""" + from app.collectors.storj import StorjCollector + + resp = _mock_response(200, {}) + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.storj.httpx.AsyncClient", return_value=client): + c = StorjCollector() + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_packetstream_api_endpoint_pattern(self): + """Cover packetstream.py lines 74-75: API endpoint pattern.""" + from app.collectors.packetstream import PacketStreamCollector + + html = '"balance":3.50' + resp = _mock_response(200, url="https://app.packetstream.io/dashboard") + resp.text = html + client = _make_async_client() + client.get.return_value = resp + + with patch("app.collectors.packetstream.httpx.AsyncClient", return_value=client): + c = PacketStreamCollector(auth_token="jwt") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_earnapp_balance_field_missing(self): + """Cover earnapp.py line 39: response without balance field.""" + from app.collectors.earnapp import EarnAppCollector + + xsrf_resp = _mock_response(200) + no_balance_resp = _mock_response(200, {"earnings_total": 0}) + + client = _make_async_client() + client.cookies = MagicMock() + client.cookies.items.return_value = [("xsrf-token", "val")] + client.get.side_effect = [xsrf_resp, no_balance_resp] + + with patch("app.collectors.earnapp.httpx.AsyncClient", return_value=client): + c = EarnAppCollector(oauth_token="tok") + result = asyncio.run(c.collect()) + assert isinstance(result, EarningsResult) + + def test_grass_epoch_fallback_no_devices(self): + """Cover grass.py lines 100-101, 107-108, 117: active epoch with empty devices.""" + from app.collectors.grass import GrassCollector + + user_resp = _mock_response(200, {"result": {"data": {"totalPoints": 0}}}) + devices_resp = _mock_response(200, {"result": {"data": []}}) + + client = _make_async_client() + client.get.side_effect = [user_resp, devices_resp] + + with patch("app.collectors.grass.httpx.AsyncClient", return_value=client): + c = GrassCollector(access_token="test-token") + result = asyncio.run(c.collect()) + assert result.balance == 0.0 + + +# --------------------------------------------------------------------------- +# database.py — encryption edge cases +# --------------------------------------------------------------------------- + + +class TestDatabaseEncryptionEdgeCases: + """Cover database.py lines 57-61, 66-68, 90-92: Fernet key loading edge cases.""" + + def test_decrypt_invalid_token(self): + """Cover line 90-92: decrypt with invalid ciphertext.""" + result = database.decrypt_value("enc:invalid-base64-garbage") + assert result == "" + + def test_is_secret_key_various(self): + assert database._is_secret_key("my_service_token") is True + assert database._is_secret_key("my_service_secret_key") is True + assert database._is_secret_key("my_service_session_cookie") is True + assert database._is_secret_key("my_service_brd_sess_id") is True + assert database._is_secret_key("my_service_remember_web") is True + assert database._is_secret_key("my_service_xsrf_token") is True + assert database._is_secret_key("my_service_email") is False + assert database._is_secret_key("plain_setting") is False + + +# --------------------------------------------------------------------------- +# fleet_key.py — edge cases +# --------------------------------------------------------------------------- + + +class TestFleetKeyEdgeCases: + """Cover fleet_key.py lines 40-41, 61-62.""" + + def test_existing_key_file_logged(self, tmp_path): + from app import fleet_key + + key_dir = tmp_path / "fleet" + key_dir.mkdir() + key_file = key_dir / ".fleet_key" + key_file.write_text("stored-key-123") + + with ( + patch.object(fleet_key, "_FLEET_KEY_DIR", key_dir), + patch.object(fleet_key, "_FLEET_KEY_FILE", key_file), + patch.dict(os.environ, {"CASHPILOT_API_KEY": ""}), + ): + result = fleet_key.resolve_fleet_key() + assert result == "stored-key-123" + + +# --------------------------------------------------------------------------- +# auth.py — OSError reading key file +# --------------------------------------------------------------------------- + + +class TestAuthOSError: + """Cover auth.py lines 56-57: OSError reading persisted key.""" + + def test_resolve_secret_key_oserror_reading(self, tmp_path): + from app import auth + + with ( + patch.dict(os.environ, {"CASHPILOT_SECRET_KEY": "", "CASHPILOT_DATA_DIR": str(tmp_path)}), + patch("app.auth.Path") as mock_path_cls, + ): + mock_data_dir = MagicMock() + mock_key_file = MagicMock() + mock_key_file.is_file.return_value = True + mock_key_file.read_text.side_effect = OSError("permission denied") + mock_data_dir.__truediv__ = MagicMock(return_value=mock_key_file) + mock_path_cls.return_value = mock_data_dir + result = auth._resolve_secret_key() + # Should generate a new key since reading failed + assert len(result) > 20 From 58904787519b6c4350575e65985245fefe8733a5 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:32:14 +0200 Subject: [PATCH 3/5] fix: resolve ruff lint errors in test files Fix import sorting (I001), nested with statements (SIM117), unused variable (F841), and blind exception catch (B017). --- tests/test_coverage_gaps.py | 8 +++----- tests/test_main_routes.py | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py index 47177b5..620f814 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/test_coverage_gaps.py @@ -8,7 +8,6 @@ import json import os from contextlib import asynccontextmanager -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") @@ -18,9 +17,8 @@ import yaml from app import catalog, database -from app.collectors import _COLLECTOR_ARGS, COLLECTOR_MAP, make_collectors -from app.collectors.base import BaseCollector, EarningsResult - +from app.collectors import COLLECTOR_MAP, make_collectors +from app.collectors.base import EarningsResult # --------------------------------------------------------------------------- # Helpers @@ -603,7 +601,7 @@ def test_set_config_skips_docker_service(self, client): _auth_owner(), patch("app.main.database.set_config_bulk", new_callable=AsyncMock), patch("app.main.catalog.get_service", return_value=svc), - patch("app.main.database.get_deployment", new_callable=AsyncMock) as mock_dep, + patch("app.main.database.get_deployment", new_callable=AsyncMock), patch("app.main.database.save_deployment", new_callable=AsyncMock) as mock_save, ): resp = client.post("/api/config", json={"data": { diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py index 55a353b..9d0e950 100644 --- a/tests/test_main_routes.py +++ b/tests/test_main_routes.py @@ -8,7 +8,7 @@ import json import os from contextlib import asynccontextmanager -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") @@ -104,9 +104,8 @@ def test_auto_resolve_single_worker(self): def test_no_workers_raises_503(self): from app.main import _resolve_worker_id - with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]): - with pytest.raises(Exception, match="No workers online"): - asyncio.run(_resolve_worker_id(None)) + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), pytest.raises(Exception, match="No workers online"): + asyncio.run(_resolve_worker_id(None)) def test_multiple_workers_raises_400(self): from app.main import _resolve_worker_id @@ -114,9 +113,8 @@ def test_multiple_workers_raises_400(self): {"id": 1, "status": "online"}, {"id": 2, "status": "online"}, ] - with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): - with pytest.raises(Exception, match="worker_id is required"): - asyncio.run(_resolve_worker_id(None)) + with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), pytest.raises(Exception, match="worker_id is required"): + asyncio.run(_resolve_worker_id(None)) class TestGetAllWorkerContainers: @@ -1007,7 +1005,7 @@ def test_invalid_scheme(self): def test_no_host(self): from app.main import _validate_worker_url - with pytest.raises(Exception): + with pytest.raises(ValueError): _validate_worker_url("http://") def test_loopback_blocked(self): @@ -1056,8 +1054,9 @@ def test_run_data_retention_error(self): asyncio.run(_run_data_retention()) # Should not raise def test_check_stale_workers(self): - from app.main import _check_stale_workers from datetime import UTC, datetime, timedelta + + from app.main import _check_stale_workers old_time = (datetime.now(UTC) - timedelta(seconds=300)).isoformat() workers = [{"id": 1, "name": "w1", "status": "online", "last_heartbeat": old_time}] with ( @@ -1073,8 +1072,8 @@ def test_check_stale_workers_error(self): asyncio.run(_check_stale_workers()) # Should not raise def test_run_collection(self): - from app.main import _run_collection from app.collectors.base import EarningsResult + from app.main import _run_collection mock_collector = AsyncMock() mock_collector.collect.return_value = EarningsResult(platform="test", balance=5.0, currency="USD") with ( @@ -1086,8 +1085,8 @@ def test_run_collection(self): asyncio.run(_run_collection()) def test_run_collection_with_error(self): - from app.main import _run_collection from app.collectors.base import EarningsResult + from app.main import _run_collection mock_collector = AsyncMock() mock_collector.collect.return_value = EarningsResult(platform="test", balance=0.0, error="API failed") with ( From 034abb5b16370f52e85d6f742a1c7c56a2b3d820 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:38:18 +0200 Subject: [PATCH 4/5] fix: resolve remaining ruff lint and test errors Fix unused imports (F401), ambiguous variable name (E741), nested with statements (SIM117), and HTTPException test assertion. --- tests/test_auth_extended.py | 1 - tests/test_catalog_loader.py | 1 - tests/test_collectors_deep.py | 1 - tests/test_collectors_extended.py | 6 +++--- tests/test_compose.py | 17 +++++++---------- tests/test_database.py | 1 - tests/test_main_deploy_routes.py | 1 - tests/test_main_routes.py | 2 +- 8 files changed, 11 insertions(+), 19 deletions(-) diff --git a/tests/test_auth_extended.py b/tests/test_auth_extended.py index fb83a65..9f79aa9 100644 --- a/tests/test_auth_extended.py +++ b/tests/test_auth_extended.py @@ -5,7 +5,6 @@ os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") -import pytest from app import auth diff --git a/tests/test_catalog_loader.py b/tests/test_catalog_loader.py index f92e5cb..2f2ab0d 100644 --- a/tests/test_catalog_loader.py +++ b/tests/test_catalog_loader.py @@ -1,7 +1,6 @@ """Tests for the catalog module's load/get logic.""" import os -from pathlib import Path from unittest.mock import patch os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") diff --git a/tests/test_collectors_deep.py b/tests/test_collectors_deep.py index 5fcd471..2f08518 100644 --- a/tests/test_collectors_deep.py +++ b/tests/test_collectors_deep.py @@ -7,7 +7,6 @@ os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") -import pytest def _make_async_client(): diff --git a/tests/test_collectors_extended.py b/tests/test_collectors_extended.py index 924ccdb..0b17d31 100644 --- a/tests/test_collectors_extended.py +++ b/tests/test_collectors_extended.py @@ -12,8 +12,7 @@ import pytest -from app.collectors.base import BaseCollector, EarningsResult - +from app.collectors.base import BaseCollector # --------------------------------------------------------------------------- # Helpers @@ -613,9 +612,10 @@ def test_collect_success_current_month(self): assert result.balance == 3.0 # (150+50+100)/100 def test_collect_connect_error(self): - from app.collectors.storj import StorjCollector import httpx as _httpx + from app.collectors.storj import StorjCollector + client = _make_async_client() client.get.side_effect = _httpx.ConnectError("Connection refused") diff --git a/tests/test_compose.py b/tests/test_compose.py index e773645..6dfd132 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -119,20 +119,18 @@ def test_generates_valid_yaml(self): output = compose_generator.generate_compose_single("honeygain") assert "Generated by CashPilot" in output # Parse YAML (skip the comment header) - lines = [l for l in output.split("\n") if not l.startswith("#")] + lines = [line for line in output.split("\n") if not line.startswith("#")] parsed = yaml.safe_load("\n".join(lines)) assert "services" in parsed def test_unknown_service_raises(self): - with patch("app.compose_generator.get_service", return_value=None): - with pytest.raises(ValueError, match="Unknown service"): - compose_generator.generate_compose_single("nope") + with patch("app.compose_generator.get_service", return_value=None), pytest.raises(ValueError, match="Unknown service"): + compose_generator.generate_compose_single("nope") def test_no_image_raises(self): svc = {"name": "No Image", "slug": "noimg", "docker": {}} - with patch("app.compose_generator.get_service", return_value=svc): - with pytest.raises(ValueError, match="no Docker image"): - compose_generator.generate_compose_single("noimg") + with patch("app.compose_generator.get_service", return_value=svc), pytest.raises(ValueError, match="no Docker image"): + compose_generator.generate_compose_single("noimg") class TestGenerateComposeMulti: @@ -149,9 +147,8 @@ def mock_get(slug): assert "cashpilot-svc2" in output def test_empty_list_raises(self): - with patch("app.compose_generator.get_service", return_value=None): - with pytest.raises(ValueError, match="No deployable"): - compose_generator.generate_compose_multi(["nonexistent"]) + with patch("app.compose_generator.get_service", return_value=None), pytest.raises(ValueError, match="No deployable"): + compose_generator.generate_compose_multi(["nonexistent"]) class TestGenerateComposeAll: diff --git a/tests/test_database.py b/tests/test_database.py index e27a883..5c0a144 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -2,7 +2,6 @@ import asyncio import os -from pathlib import Path from unittest.mock import patch os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") diff --git a/tests/test_main_deploy_routes.py b/tests/test_main_deploy_routes.py index e518da9..1a1f6bc 100644 --- a/tests/test_main_deploy_routes.py +++ b/tests/test_main_deploy_routes.py @@ -4,7 +4,6 @@ and the database layer. """ -import asyncio import json import os from contextlib import asynccontextmanager diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py index 9d0e950..5a8cd53 100644 --- a/tests/test_main_routes.py +++ b/tests/test_main_routes.py @@ -1005,7 +1005,7 @@ def test_invalid_scheme(self): def test_no_host(self): from app.main import _validate_worker_url - with pytest.raises(ValueError): + with pytest.raises(Exception, match="no host"): _validate_worker_url("http://") def test_loopback_blocked(self): From c1f0b2c03b8e780632f4ec32e784ecb20b565833 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:41:42 +0200 Subject: [PATCH 5/5] style: apply ruff format to test files --- tests/test_catalog_loader.py | 11 +- tests/test_collectors_deep.py | 56 ++++---- tests/test_collectors_extended.py | 2 + tests/test_compose.py | 45 +++++-- tests/test_coverage_gaps.py | 206 +++++++++++++++++++----------- tests/test_database.py | 4 +- tests/test_main_deploy_routes.py | 159 ++++++++++++++++------- tests/test_main_routes.py | 171 +++++++++++++++++-------- 8 files changed, 442 insertions(+), 212 deletions(-) diff --git a/tests/test_catalog_loader.py b/tests/test_catalog_loader.py index 2f2ab0d..bb19524 100644 --- a/tests/test_catalog_loader.py +++ b/tests/test_catalog_loader.py @@ -10,9 +10,14 @@ from app import catalog -def _make_service_yaml(slug="test-svc", name="Test Service", category="bandwidth", - status="active", description="A test service", - docker=None): +def _make_service_yaml( + slug="test-svc", + name="Test Service", + category="bandwidth", + status="active", + description="A test service", + docker=None, +): data = { "name": name, "slug": slug, diff --git a/tests/test_collectors_deep.py b/tests/test_collectors_deep.py index 2f08518..d0b590e 100644 --- a/tests/test_collectors_deep.py +++ b/tests/test_collectors_deep.py @@ -8,7 +8,6 @@ os.environ.setdefault("CASHPILOT_API_KEY", "test-fleet-key") - def _make_async_client(): client = AsyncMock() client.__aenter__ = AsyncMock(return_value=client) @@ -26,6 +25,7 @@ def _mock_response(status_code=200, json_data=None, text="", url="https://exampl resp.raise_for_status = MagicMock() if status_code >= 400: import httpx + resp.raise_for_status.side_effect = httpx.HTTPStatusError( f"HTTP {status_code}", request=MagicMock(), response=resp ) @@ -87,24 +87,27 @@ def test_get_per_node_earnings(self): from app.collectors.mystnodes import MystNodesCollector login_resp = _mock_response(200, {"accessToken": "at", "refreshToken": "rt"}) - nodes_resp = _mock_response(200, { - "nodes": [ - { - "identity": "0xabc123", - "name": "node-1", - "localIp": "192.168.1.10", - "nodeStatus": {"online": True}, - "country": {"code": "US"}, - "version": "1.0.0", - "earnings": [{"etherAmount": 0.5}, {"etherAmount": 0.3}], - "lifetimeEarnings": { - "totalEther": 10.0, - "settledEther": 8.0, - "unsettledEther": 2.0, - }, - } - ] - }) + nodes_resp = _mock_response( + 200, + { + "nodes": [ + { + "identity": "0xabc123", + "name": "node-1", + "localIp": "192.168.1.10", + "nodeStatus": {"online": True}, + "country": {"code": "US"}, + "version": "1.0.0", + "earnings": [{"etherAmount": 0.5}, {"etherAmount": 0.3}], + "lifetimeEarnings": { + "totalEther": 10.0, + "settledEther": 8.0, + "unsettledEther": 2.0, + }, + } + ] + }, + ) client = _make_async_client() client.post.return_value = login_resp @@ -257,11 +260,16 @@ def test_collect_active_devices_estimation(self): # First call: settled points = 0 (active epoch) user_resp = _mock_response(200, {"result": {"data": {"totalPoints": 0}}}) # Second call: active devices - devices_resp = _mock_response(200, { - "result": {"data": [ - {"aggUptime": 3600, "ipScore": 80, "multiplier": 1.0, "ipAddress": "1.2.3.4"}, - ]} - }) + devices_resp = _mock_response( + 200, + { + "result": { + "data": [ + {"aggUptime": 3600, "ipScore": 80, "multiplier": 1.0, "ipAddress": "1.2.3.4"}, + ] + } + }, + ) client = _make_async_client() client.get.side_effect = [user_resp, devices_resp] diff --git a/tests/test_collectors_extended.py b/tests/test_collectors_extended.py index 0b17d31..fedf804 100644 --- a/tests/test_collectors_extended.py +++ b/tests/test_collectors_extended.py @@ -18,6 +18,7 @@ # Helpers # --------------------------------------------------------------------------- + def _mock_response(status_code=200, json_data=None, text="", url="https://example.com"): resp = MagicMock() resp.status_code = status_code @@ -28,6 +29,7 @@ def _mock_response(status_code=200, json_data=None, text="", url="https://exampl resp.raise_for_status = MagicMock() if status_code >= 400: import httpx as _httpx + resp.raise_for_status.side_effect = _httpx.HTTPStatusError( f"HTTP {status_code}", request=MagicMock(), response=resp ) diff --git a/tests/test_compose.py b/tests/test_compose.py index 6dfd132..d568e4b 100644 --- a/tests/test_compose.py +++ b/tests/test_compose.py @@ -11,9 +11,18 @@ from app import compose_generator -def _mock_service(slug="honeygain", name="Honeygain", image="honeygain/honeygain:latest", - env=None, ports=None, volumes=None, category="bandwidth", - network_mode=None, cap_add=None, command=None): +def _mock_service( + slug="honeygain", + name="Honeygain", + image="honeygain/honeygain:latest", + env=None, + ports=None, + volumes=None, + category="bandwidth", + network_mode=None, + cap_add=None, + command=None, +): svc = { "name": name, "slug": slug, @@ -57,10 +66,12 @@ def test_no_docker_returns_none(self): assert result is None def test_env_vars_included(self): - svc = _mock_service(env=[ - {"key": "EMAIL", "default": "user@example.com"}, - {"key": "PASSWORD", "default": ""}, - ]) + svc = _mock_service( + env=[ + {"key": "EMAIL", "default": "user@example.com"}, + {"key": "PASSWORD", "default": ""}, + ] + ) result = compose_generator._service_to_compose(svc) assert "EMAIL" in result["environment"] assert result["environment"]["EMAIL"] == "user@example.com" @@ -124,12 +135,18 @@ def test_generates_valid_yaml(self): assert "services" in parsed def test_unknown_service_raises(self): - with patch("app.compose_generator.get_service", return_value=None), pytest.raises(ValueError, match="Unknown service"): + with ( + patch("app.compose_generator.get_service", return_value=None), + pytest.raises(ValueError, match="Unknown service"), + ): compose_generator.generate_compose_single("nope") def test_no_image_raises(self): svc = {"name": "No Image", "slug": "noimg", "docker": {}} - with patch("app.compose_generator.get_service", return_value=svc), pytest.raises(ValueError, match="no Docker image"): + with ( + patch("app.compose_generator.get_service", return_value=svc), + pytest.raises(ValueError, match="no Docker image"), + ): compose_generator.generate_compose_single("noimg") @@ -147,7 +164,10 @@ def mock_get(slug): assert "cashpilot-svc2" in output def test_empty_list_raises(self): - with patch("app.compose_generator.get_service", return_value=None), pytest.raises(ValueError, match="No deployable"): + with ( + patch("app.compose_generator.get_service", return_value=None), + pytest.raises(ValueError, match="No deployable"), + ): compose_generator.generate_compose_multi(["nonexistent"]) @@ -159,7 +179,10 @@ def test_generates_all(self): ] with ( patch("app.compose_generator.get_services", return_value=svcs), - patch("app.compose_generator.get_service", side_effect=lambda s: next((x for x in svcs if x["slug"] == s), None)), + patch( + "app.compose_generator.get_service", + side_effect=lambda s: next((x for x in svcs if x["slug"] == s), None), + ), ): output = compose_generator.generate_compose_all() assert "cashpilot-a" in output diff --git a/tests/test_coverage_gaps.py b/tests/test_coverage_gaps.py index 620f814..48966f0 100644 --- a/tests/test_coverage_gaps.py +++ b/tests/test_coverage_gaps.py @@ -24,6 +24,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_async_client(): client = AsyncMock() client.__aenter__ = AsyncMock(return_value=client) @@ -46,9 +47,14 @@ def _mock_response(status_code=200, json_data=None, text="", url="https://exampl return resp -def _make_service_yaml(slug="test-svc", name="Test Service", category="bandwidth", - status="active", description="A test service", - docker=None): +def _make_service_yaml( + slug="test-svc", + name="Test Service", + category="bandwidth", + status="active", + description="A test service", + docker=None, +): data = { "name": name, "slug": slug, @@ -156,6 +162,7 @@ def test_sighup_handler_reloads(self, tmp_path): def test_register_sighup_on_unix(self): import signal import sys + if sys.platform == "win32": pytest.skip("Unix only") with patch("signal.signal") as mock_signal: @@ -260,10 +267,7 @@ def test_parse_balance_all_zero(self): def test_parse_balance_first_zero_second_nonzero(self): from app.collectors.bytelixir import BytelixirCollector - html = ( - '$0.00000' - '$1.23456' - ) + html = '$0.00000$1.23456' result = BytelixirCollector._parse_balance_from_html(html) assert result == 1.23456 @@ -302,8 +306,10 @@ def _no_auth(): @pytest.fixture def client(): from app.main import app + app.router.lifespan_context = _noop_lifespan from fastapi.testclient import TestClient + with TestClient(app, raise_server_exceptions=False) as c: yield c @@ -314,15 +320,21 @@ class TestMainHealthCheckWithContainers: def test_health_check_records_events(self): from app.main import _run_health_check - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([ - {"slug": "honeygain", "name": "hg", "status": "running"}, - {"slug": "earnapp", "name": "ea", "status": "stopped"}, - ]), - "apps": "[]", - }] + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps( + [ + {"slug": "honeygain", "name": "hg", "status": "running"}, + {"slug": "earnapp", "name": "ea", "status": "stopped"}, + ] + ), + "apps": "[]", + } + ] mock_record = AsyncMock() with ( patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), @@ -359,8 +371,7 @@ class TestMainRunCollectionException: def test_run_collection_total_exception(self): from app.main import _run_collection - with patch("app.main.database.get_deployments", new_callable=AsyncMock, - side_effect=Exception("DB down")): + with patch("app.main.database.get_deployments", new_callable=AsyncMock, side_effect=Exception("DB down")): asyncio.run(_run_collection()) # Should not raise @@ -369,7 +380,8 @@ class TestMainDeployCommandEdgeCases: def test_deploy_with_command_substitution(self, client): svc = { - "slug": "test-cmd", "name": "TestCmd", + "slug": "test-cmd", + "name": "TestCmd", "docker": { "image": "test:latest", "env": [{"key": "TOKEN", "default": "abc"}], @@ -403,7 +415,8 @@ def test_deploy_with_command_substitution(self, client): def test_deploy_with_network_mode_and_cap_add(self, client): svc = { - "slug": "test-net", "name": "TestNet", + "slug": "test-net", + "name": "TestNet", "docker": { "image": "test:latest", "env": [], @@ -443,7 +456,8 @@ class TestMainProxyWorkerDeployError: def test_proxy_deploy_error_response(self, client): svc = { - "slug": "hg", "name": "Honeygain", + "slug": "hg", + "name": "Honeygain", "docker": {"image": "hg:latest", "env": [], "ports": [], "volumes": []}, } worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} @@ -471,7 +485,8 @@ def test_proxy_deploy_error_response(self, client): def test_proxy_deploy_httpx_error(self, client): svc = { - "slug": "hg", "name": "Honeygain", + "slug": "hg", + "name": "Honeygain", "docker": {"image": "hg:latest", "env": [], "ports": [], "volumes": []}, } worker = {"id": 1, "name": "w1", "status": "online", "url": "http://192.168.1.10:8081"} @@ -546,8 +561,7 @@ def test_compose_single_value_error(self, client): with ( _auth_owner(), patch("app.main.catalog.get_service", return_value=svc), - patch("app.main.compose_generator.generate_compose_single", - side_effect=ValueError("no image")), + patch("app.main.compose_generator.generate_compose_single", side_effect=ValueError("no image")), ): resp = client.get("/api/compose/bad") assert resp.status_code == 400 @@ -555,8 +569,7 @@ def test_compose_single_value_error(self, client): def test_compose_multi_value_error(self, client): with ( _auth_owner(), - patch("app.main.compose_generator.generate_compose_multi", - side_effect=ValueError("no services")), + patch("app.main.compose_generator.generate_compose_multi", side_effect=ValueError("no services")), ): resp = client.post("/api/compose", json={"slugs": ["bad"]}) assert resp.status_code == 400 @@ -564,8 +577,7 @@ def test_compose_multi_value_error(self, client): def test_compose_all_value_error(self, client): with ( _auth_owner(), - patch("app.main.compose_generator.generate_compose_all", - side_effect=ValueError("no services")), + patch("app.main.compose_generator.generate_compose_all", side_effect=ValueError("no services")), ): resp = client.get("/api/compose") assert resp.status_code == 400 @@ -576,15 +588,17 @@ class TestMainPerNodeEarnings: def test_per_node_earnings_mysterium(self, client): mock_collector = MagicMock() - mock_collector.get_per_node_earnings = AsyncMock(return_value=[ - {"identity": "0xabc", "earnings_myst": 5.0} - ]) + mock_collector.get_per_node_earnings = AsyncMock(return_value=[{"identity": "0xabc", "earnings_myst": 5.0}]) with ( _auth_owner(), - patch("app.main.database.get_config", new_callable=AsyncMock, return_value={ - "mysterium_email": "test@test.com", - "mysterium_password": "pass", - }), + patch( + "app.main.database.get_config", + new_callable=AsyncMock, + return_value={ + "mysterium_email": "test@test.com", + "mysterium_password": "pass", + }, + ), patch("app.collectors.mystnodes.MystNodesCollector", return_value=mock_collector), ): resp = client.get("/api/services/mysterium/per-node-earnings") @@ -604,10 +618,15 @@ def test_set_config_skips_docker_service(self, client): patch("app.main.database.get_deployment", new_callable=AsyncMock), patch("app.main.database.save_deployment", new_callable=AsyncMock) as mock_save, ): - resp = client.post("/api/config", json={"data": { - "honeygain_email": "test@test.com", - "honeygain_password": "pass", - }}) + resp = client.post( + "/api/config", + json={ + "data": { + "honeygain_email": "test@test.com", + "honeygain_password": "pass", + } + }, + ) assert resp.status_code == 200 mock_save.assert_not_called() @@ -651,9 +670,13 @@ def test_worker_command_no_url(self, client): _auth_writer(), patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), ): - resp = client.post("/api/workers/1/command", json={ - "command": "stop", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "stop", + "slug": "honeygain", + }, + ) assert resp.status_code == 503 @@ -678,9 +701,14 @@ def test_worker_command_deploy_error(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "deploy", "slug": "honeygain", "spec": {"image": "test"}, - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "deploy", + "slug": "honeygain", + "spec": {"image": "test"}, + }, + ) assert resp.status_code == 500 @@ -691,12 +719,10 @@ def test_earnings_summary_worker_exception(self, client): summary = {"total": 10.0, "today": 1.0, "month": 5.0, "today_change": 0.5} with ( _auth_owner(), - patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, - return_value=summary), + patch("app.main.database.get_earnings_dashboard_summary", new_callable=AsyncMock, return_value=summary), patch("app.main.database.get_config", new_callable=AsyncMock, return_value={}), patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, return_value=[]), - patch("app.main._get_all_worker_containers", new_callable=AsyncMock, - side_effect=Exception("worker error")), + patch("app.main._get_all_worker_containers", new_callable=AsyncMock, side_effect=Exception("worker error")), ): resp = client.get("/api/earnings/summary") assert resp.status_code == 200 @@ -707,19 +733,38 @@ class TestMainServicesDeployedMultiStatus: """Cover lines 605, 626-627: deployed services with various statuses.""" def test_deployed_services_with_cashout_and_referral(self, client): - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([ - {"slug": "hg", "name": "hg", "status": "restarting", "image": "hg:latest", - "cpu_percent": 0.5, "memory_mb": 25}, - {"slug": "hg", "name": "hg-2", "status": "running", "image": "hg:latest", - "cpu_percent": 1.0, "memory_mb": 30}, - ]), - "apps": "[]", - }] + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps( + [ + { + "slug": "hg", + "name": "hg", + "status": "restarting", + "image": "hg:latest", + "cpu_percent": 0.5, + "memory_mb": 25, + }, + { + "slug": "hg", + "name": "hg-2", + "status": "running", + "image": "hg:latest", + "cpu_percent": 1.0, + "memory_mb": 30, + }, + ] + ), + "apps": "[]", + } + ] svc = { - "name": "Honeygain", "category": "bandwidth", + "name": "Honeygain", + "category": "bandwidth", "cashout": {"min_amount": 20}, "referral": {"signup_url": "https://r.hg.com"}, "website": "https://honeygain.com", @@ -728,8 +773,11 @@ def test_deployed_services_with_cashout_and_referral(self, client): with ( _auth_owner(), patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), - patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, - return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}]), + patch( + "app.main.database.get_earnings_summary", + new_callable=AsyncMock, + return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}], + ), patch("app.main.database.get_health_scores", new_callable=AsyncMock, return_value=[]), patch("app.main.database.get_deployments", new_callable=AsyncMock, return_value=[]), patch("app.main.catalog.get_service", return_value=svc), @@ -748,12 +796,16 @@ class TestMainServicesAvailableNodeCounts: def test_services_available_with_node_counts(self, client): svcs = [{"slug": "hg", "name": "HG", "status": "active", "docker": {"image": "test"}}] - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), - "apps": "[]", - }] + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), + "apps": "[]", + } + ] deps = [{"slug": "hg"}] with ( _auth_owner(), @@ -773,12 +825,16 @@ class TestMainGetServiceEnriched: def test_get_service_with_worker_data(self, client): svc = {"slug": "hg", "name": "HG", "docker": {"image": "test"}} - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), - "apps": "[]", - }] + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "hg", "name": "hg", "status": "running"}]), + "apps": "[]", + } + ] with ( _auth_owner(), patch("app.main.catalog.get_service", return_value=svc), diff --git a/tests/test_database.py b/tests/test_database.py index 5c0a144..62a56f9 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -34,9 +34,7 @@ def test_creates_tables(self, db): async def check(): conn = await database._get_db() try: - cursor = await conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" - ) + cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") tables = {row["name"] for row in await cursor.fetchall()} assert "earnings" in tables assert "config" in tables diff --git a/tests/test_main_deploy_routes.py b/tests/test_main_deploy_routes.py index 1a1f6bc..e5fd4bd 100644 --- a/tests/test_main_deploy_routes.py +++ b/tests/test_main_deploy_routes.py @@ -23,6 +23,7 @@ async def _noop_lifespan(a): yield + app.router.lifespan_context = _noop_lifespan @@ -73,7 +74,8 @@ def _mock_httpx_resp(status_code=200, json_data=None): class TestApiDeploy: def test_deploy_success(self, client): svc = { - "slug": "honeygain", "name": "Honeygain", + "slug": "honeygain", + "name": "Honeygain", "docker": { "image": "honeygain/honeygain:latest", "env": [{"key": "EMAIL", "default": "user@test.com"}], @@ -91,8 +93,7 @@ def test_deploy_success(self, client): with ( _auth_writer(), - patch("app.main.database.list_workers", new_callable=AsyncMock, - return_value=[worker]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[worker]), patch("app.main.catalog.get_service", return_value=svc), patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), patch("app.main.database.save_deployment", new_callable=AsyncMock), @@ -108,8 +109,7 @@ def test_deploy_success(self, client): def test_deploy_service_not_found(self, client): with ( _auth_writer(), - patch("app.main.database.list_workers", new_callable=AsyncMock, - return_value=[_online_worker()]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[_online_worker()]), patch("app.main.catalog.get_service", return_value=None), ): resp = client.post("/api/deploy/nope", json={}) @@ -119,8 +119,7 @@ def test_deploy_no_image(self, client): svc = {"slug": "grass", "name": "Grass", "docker": {}} with ( _auth_writer(), - patch("app.main.database.list_workers", new_callable=AsyncMock, - return_value=[_online_worker()]), + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[_online_worker()]), patch("app.main.catalog.get_service", return_value=svc), ): resp = client.post("/api/deploy/grass", json={}) @@ -356,9 +355,14 @@ def test_command_deploy(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "deploy", "slug": "honeygain", "spec": {"image": "test"}, - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "deploy", + "slug": "honeygain", + "spec": {"image": "test"}, + }, + ) assert resp.status_code == 200 def test_command_stop(self, client): @@ -369,9 +373,13 @@ def test_command_stop(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "stop", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "stop", + "slug": "honeygain", + }, + ) assert resp.status_code == 200 def test_command_remove(self, client): @@ -382,9 +390,13 @@ def test_command_remove(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "remove", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "remove", + "slug": "honeygain", + }, + ) assert resp.status_code == 200 def test_command_unknown(self, client): @@ -395,9 +407,13 @@ def test_command_unknown(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "nuke", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "nuke", + "slug": "honeygain", + }, + ) assert resp.status_code == 400 def test_command_worker_offline(self, client): @@ -406,9 +422,13 @@ def test_command_worker_offline(self, client): _auth_writer(), patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=worker), ): - resp = client.post("/api/workers/1/command", json={ - "command": "stop", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "stop", + "slug": "honeygain", + }, + ) assert resp.status_code == 503 def test_command_worker_not_found(self, client): @@ -416,9 +436,13 @@ def test_command_worker_not_found(self, client): _auth_writer(), patch("app.main.database.get_worker", new_callable=AsyncMock, return_value=None), ): - resp = client.post("/api/workers/99/command", json={ - "command": "stop", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/99/command", + json={ + "command": "stop", + "slug": "honeygain", + }, + ) assert resp.status_code == 404 def test_command_httpx_error(self, client): @@ -434,9 +458,13 @@ def test_command_httpx_error(self, client): patch("app.main.httpx.AsyncClient", return_value=mock_client), patch("app.main.FLEET_API_KEY", "test-key"), ): - resp = client.post("/api/workers/1/command", json={ - "command": "restart", "slug": "honeygain", - }) + resp = client.post( + "/api/workers/1/command", + json={ + "command": "restart", + "slug": "honeygain", + }, + ) assert resp.status_code == 503 @@ -447,17 +475,36 @@ def test_command_httpx_error(self, client): class TestDeployedServicesAggregation: def test_aggregation_with_workers_and_earnings(self, client): - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([ - {"slug": "honeygain", "name": "hg", "status": "running", "image": "hg:latest", "cpu_percent": 1.5, "memory_mb": 50}, - ]), - "apps": "[]", - }] + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps( + [ + { + "slug": "honeygain", + "name": "hg", + "status": "running", + "image": "hg:latest", + "cpu_percent": 1.5, + "memory_mb": 50, + }, + ] + ), + "apps": "[]", + } + ] earnings = [{"platform": "honeygain", "balance": 5.0, "currency": "USD"}] health = [{"slug": "honeygain", "score": 95, "uptime_pct": 99, "restarts": 0}] - svc = {"name": "Honeygain", "category": "bandwidth", "cashout": {"min_amount": 20}, "referral": {"signup_url": "https://r.hg.com"}, "website": "https://honeygain.com"} + svc = { + "name": "Honeygain", + "category": "bandwidth", + "cashout": {"min_amount": 20}, + "referral": {"signup_url": "https://r.hg.com"}, + "website": "https://honeygain.com", + } with ( _auth_owner(), @@ -479,19 +526,41 @@ def test_aggregation_with_workers_and_earnings(self, client): def test_multi_node_aggregation(self, client): workers = [ { - "id": 1, "name": "w1", "status": "online", + "id": 1, + "name": "w1", + "status": "online", "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([ - {"slug": "honeygain", "name": "hg-1", "status": "running", "image": "hg:latest", "cpu_percent": 1.0, "memory_mb": 30}, - ]), + "containers": json.dumps( + [ + { + "slug": "honeygain", + "name": "hg-1", + "status": "running", + "image": "hg:latest", + "cpu_percent": 1.0, + "memory_mb": 30, + }, + ] + ), "apps": "[]", }, { - "id": 2, "name": "w2", "status": "online", + "id": 2, + "name": "w2", + "status": "online", "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([ - {"slug": "honeygain", "name": "hg-2", "status": "running", "image": "hg:latest", "cpu_percent": 2.0, "memory_mb": 40}, - ]), + "containers": json.dumps( + [ + { + "slug": "honeygain", + "name": "hg-2", + "status": "running", + "image": "hg:latest", + "cpu_percent": 2.0, + "memory_mb": 40, + }, + ] + ), "apps": "[]", }, ] diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py index 5a8cd53..dad37aa 100644 --- a/tests/test_main_routes.py +++ b/tests/test_main_routes.py @@ -31,6 +31,7 @@ async def _noop_lifespan(a): # Helpers # --------------------------------------------------------------------------- + def _owner_user(): return {"uid": 1, "u": "admin", "r": "owner"} @@ -74,58 +75,78 @@ def client(): class TestSafeJson: def test_valid_json(self): from app.main import _safe_json + assert _safe_json('{"a": 1}') == {"a": 1} def test_invalid_json_fallback(self): from app.main import _safe_json + assert _safe_json("not json") == [] def test_invalid_json_custom_fallback(self): from app.main import _safe_json + assert _safe_json("bad", {}) == {} def test_none_input(self): from app.main import _safe_json + assert _safe_json(None) == [] class TestResolveWorkerId: def test_explicit_worker_id(self): from app.main import _resolve_worker_id + result = asyncio.run(_resolve_worker_id(42)) assert result == 42 def test_auto_resolve_single_worker(self): from app.main import _resolve_worker_id - with patch("app.main.database.list_workers", new_callable=AsyncMock, - return_value=[{"id": 7, "status": "online"}]): + + with patch( + "app.main.database.list_workers", new_callable=AsyncMock, return_value=[{"id": 7, "status": "online"}] + ): result = asyncio.run(_resolve_worker_id(None)) assert result == 7 def test_no_workers_raises_503(self): from app.main import _resolve_worker_id - with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), pytest.raises(Exception, match="No workers online"): + + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), + pytest.raises(Exception, match="No workers online"), + ): asyncio.run(_resolve_worker_id(None)) def test_multiple_workers_raises_400(self): from app.main import _resolve_worker_id + workers = [ {"id": 1, "status": "online"}, {"id": 2, "status": "online"}, ] - with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), pytest.raises(Exception, match="worker_id is required"): + with ( + patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers), + pytest.raises(Exception, match="worker_id is required"), + ): asyncio.run(_resolve_worker_id(None)) class TestGetAllWorkerContainers: def test_docker_containers(self): from app.main import _get_all_worker_containers - workers = [{ - "id": 1, "name": "w1", "status": "online", - "system_info": json.dumps({"docker_available": True}), - "containers": json.dumps([{"slug": "honeygain", "name": "hg", "status": "running"}]), - "apps": "[]", - }] + + workers = [ + { + "id": 1, + "name": "w1", + "status": "online", + "system_info": json.dumps({"docker_available": True}), + "containers": json.dumps([{"slug": "honeygain", "name": "hg", "status": "running"}]), + "apps": "[]", + } + ] with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): result = asyncio.run(_get_all_worker_containers()) assert len(result) == 1 @@ -134,12 +155,17 @@ def test_docker_containers(self): def test_android_apps(self): from app.main import _get_all_worker_containers - workers = [{ - "id": 2, "name": "phone", "status": "online", - "system_info": json.dumps({"device_type": "android"}), - "containers": "[]", - "apps": json.dumps([{"slug": "earnapp", "running": True}]), - }] + + workers = [ + { + "id": 2, + "name": "phone", + "status": "online", + "system_info": json.dumps({"device_type": "android"}), + "containers": "[]", + "apps": json.dumps([{"slug": "earnapp", "running": True}]), + } + ] with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): result = asyncio.run(_get_all_worker_containers()) assert len(result) == 1 @@ -148,6 +174,7 @@ def test_android_apps(self): def test_offline_workers_skipped(self): from app.main import _get_all_worker_containers + workers = [{"id": 1, "name": "w1", "status": "offline", "system_info": "{}", "containers": "[]", "apps": "[]"}] with patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=workers): result = asyncio.run(_get_all_worker_containers()) @@ -239,11 +266,15 @@ def test_register_first_user_success(self, client): patch("app.main.auth.create_session_token", return_value="tok"), patch("app.main.auth.set_session_cookie", side_effect=lambda r, t: r), ): - resp = client.post("/register", data={ - "username": "admin", - "password": "password123", - "password_confirm": "password123", - }, follow_redirects=False) + resp = client.post( + "/register", + data={ + "username": "admin", + "password": "password123", + "password_confirm": "password123", + }, + follow_redirects=False, + ) assert resp.status_code == 303 def test_register_password_mismatch(self, client): @@ -251,11 +282,14 @@ def test_register_password_mismatch(self, client): _no_auth(), patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), ): - resp = client.post("/register", data={ - "username": "admin", - "password": "password123", - "password_confirm": "different", - }) + resp = client.post( + "/register", + data={ + "username": "admin", + "password": "password123", + "password_confirm": "different", + }, + ) assert resp.status_code == 400 def test_register_password_too_short(self, client): @@ -263,11 +297,14 @@ def test_register_password_too_short(self, client): _no_auth(), patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), ): - resp = client.post("/register", data={ - "username": "admin", - "password": "short", - "password_confirm": "short", - }) + resp = client.post( + "/register", + data={ + "username": "admin", + "password": "short", + "password_confirm": "short", + }, + ) assert resp.status_code == 400 def test_register_bad_username(self, client): @@ -275,11 +312,14 @@ def test_register_bad_username(self, client): _no_auth(), patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), ): - resp = client.post("/register", data={ - "username": "a", - "password": "password123", - "password_confirm": "password123", - }) + resp = client.post( + "/register", + data={ + "username": "a", + "password": "password123", + "password_confirm": "password123", + }, + ) assert resp.status_code == 400 def test_register_duplicate_username(self, client): @@ -288,11 +328,14 @@ def test_register_duplicate_username(self, client): patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=False), patch("app.main.database.get_user_by_username", new_callable=AsyncMock, return_value={"id": 1}), ): - resp = client.post("/register", data={ - "username": "admin", - "password": "password123", - "password_confirm": "password123", - }) + resp = client.post( + "/register", + data={ + "username": "admin", + "password": "password123", + "password_confirm": "password123", + }, + ) assert resp.status_code == 400 def test_register_non_first_user_non_owner_forbidden(self, client): @@ -300,11 +343,14 @@ def test_register_non_first_user_non_owner_forbidden(self, client): _auth_viewer(), patch("app.main.database.has_any_users", new_callable=AsyncMock, return_value=True), ): - resp = client.post("/register", data={ - "username": "new", - "password": "password123", - "password_confirm": "password123", - }) + resp = client.post( + "/register", + data={ + "username": "new", + "password": "password123", + "password_confirm": "password123", + }, + ) assert resp.status_code == 403 @@ -523,8 +569,11 @@ class TestApiEarnings: def test_api_earnings(self, client): with ( _auth_owner(), - patch("app.main.database.get_earnings_summary", new_callable=AsyncMock, - return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}]), + patch( + "app.main.database.get_earnings_summary", + new_callable=AsyncMock, + return_value=[{"platform": "hg", "balance": 5.0, "currency": "USD"}], + ), ): resp = client.get("/api/earnings") assert resp.status_code == 200 @@ -548,8 +597,11 @@ def test_api_earnings_summary(self, client): def test_api_earnings_daily(self, client): with ( _auth_owner(), - patch("app.main.database.get_daily_earnings", new_callable=AsyncMock, - return_value=[{"date": "2026-01-01", "amount": 1.0}]), + patch( + "app.main.database.get_daily_earnings", + new_callable=AsyncMock, + return_value=[{"date": "2026-01-01", "amount": 1.0}], + ), ): resp = client.get("/api/earnings/daily?days=7") assert resp.status_code == 200 @@ -636,7 +688,9 @@ def test_api_compose_single(self, client): with ( _auth_owner(), patch("app.main.catalog.get_service", return_value=svc), - patch("app.main.compose_generator.generate_compose_single", return_value="services:\n hg:\n image: test"), + patch( + "app.main.compose_generator.generate_compose_single", return_value="services:\n hg:\n image: test" + ), ): resp = client.get("/api/compose/hg") assert resp.status_code == 200 @@ -992,34 +1046,41 @@ def test_api_fleet_api_key_non_owner(self, client): class TestValidateWorkerUrl: def test_valid_url(self): from app.main import _validate_worker_url + assert _validate_worker_url("http://192.168.1.10:8081") == "http://192.168.1.10:8081" def test_trailing_slash_stripped(self): from app.main import _validate_worker_url + assert _validate_worker_url("http://host:8081/") == "http://host:8081" def test_invalid_scheme(self): from app.main import _validate_worker_url + with pytest.raises(Exception, match="Invalid worker URL scheme"): _validate_worker_url("ftp://host:21") def test_no_host(self): from app.main import _validate_worker_url + with pytest.raises(Exception, match="no host"): _validate_worker_url("http://") def test_loopback_blocked(self): from app.main import _validate_worker_url + with pytest.raises(Exception, match="loopback"): _validate_worker_url("http://127.0.0.1:8081") def test_localhost_blocked(self): from app.main import _validate_worker_url + with pytest.raises(Exception, match="localhost"): _validate_worker_url("http://localhost:8081") def test_tailscale_dns_allowed(self): from app.main import _validate_worker_url + result = _validate_worker_url("http://worker.mango.ts.net:8081") assert "worker.mango.ts.net" in result @@ -1032,6 +1093,7 @@ def test_tailscale_dns_allowed(self): class TestPeriodicTasks: def test_run_health_check(self): from app.main import _run_health_check + with ( patch("app.main.database.list_workers", new_callable=AsyncMock, return_value=[]), patch("app.main.database.record_health_event", new_callable=AsyncMock), @@ -1040,16 +1102,19 @@ def test_run_health_check(self): def test_run_health_check_error(self): from app.main import _run_health_check + with patch("app.main.database.list_workers", new_callable=AsyncMock, side_effect=Exception("db error")): asyncio.run(_run_health_check()) # Should not raise def test_run_data_retention(self): from app.main import _run_data_retention + with patch("app.main.database.purge_old_data", new_callable=AsyncMock, return_value=5): asyncio.run(_run_data_retention()) def test_run_data_retention_error(self): from app.main import _run_data_retention + with patch("app.main.database.purge_old_data", new_callable=AsyncMock, side_effect=Exception("err")): asyncio.run(_run_data_retention()) # Should not raise @@ -1057,6 +1122,7 @@ def test_check_stale_workers(self): from datetime import UTC, datetime, timedelta from app.main import _check_stale_workers + old_time = (datetime.now(UTC) - timedelta(seconds=300)).isoformat() workers = [{"id": 1, "name": "w1", "status": "online", "last_heartbeat": old_time}] with ( @@ -1068,12 +1134,14 @@ def test_check_stale_workers(self): def test_check_stale_workers_error(self): from app.main import _check_stale_workers + with patch("app.main.database.list_workers", new_callable=AsyncMock, side_effect=Exception("err")): asyncio.run(_check_stale_workers()) # Should not raise def test_run_collection(self): from app.collectors.base import EarningsResult from app.main import _run_collection + mock_collector = AsyncMock() mock_collector.collect.return_value = EarningsResult(platform="test", balance=5.0, currency="USD") with ( @@ -1087,6 +1155,7 @@ def test_run_collection(self): def test_run_collection_with_error(self): from app.collectors.base import EarningsResult from app.main import _run_collection + mock_collector = AsyncMock() mock_collector.collect.return_value = EarningsResult(platform="test", balance=0.0, error="API failed") with (