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 (