Files
homelab/userbot/panel/backend/tests/test_kubernetes_service.py
T
forustandClaude Opus 4.8 0ae0df7473 style: format the last 10 files that ruff format disagreed with
ruff.toml has declared `quote-style = "single"` and line-length 120 since the
lint job landed, and 118 of 128 files follow it. The panel backend and the
netbox configuration were written in black/prettier style instead, so a
`ruff format --check` would have failed on them from the start.

Bring them onto the style the repository already declares, which is what makes
the check adoptable at all. Formatting only: apart from quote style the diff is
multi-line expressions joined where they fit inside 120 columns.

Both suites still pass afterwards (25 pytest, and ruff check is clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-27 10:19:23 +02:00

201 lines
6.8 KiB
Python

from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.config import Settings
from app.errors import PanelError
from app.kubernetes_service import KubernetesService
from app.models import AccountBase
from kubernetes.client.exceptions import ApiException
def service() -> KubernetesService:
core = Mock()
apps = Mock()
custom = Mock()
custom.get_namespaced_custom_object.side_effect = ApiException(status=404)
return KubernetesService(Settings(), core=core, apps=apps, custom=custom)
def account() -> AccountBase:
return AccountBase(
instance_id='test-account',
display_name='Test Account',
api_id=12345,
api_hash='0123456789abcdef0123456789abcdef',
)
def test_renders_managed_resources_without_leaking_credentials() -> None:
kube = service()
names = kube._resource_names('test-account')
secret = kube._secret(account(), 'SESSION', names)
pvc = kube._pvc(account(), names)
deployment = kube._deployment(account(), names)
assert secret.string_data == {
'API_ID': '12345',
'API_HASH': '0123456789abcdef0123456789abcdef',
'STRINGSESSION': 'SESSION',
}
assert pvc.spec.storage_class_name == 'local-path-retain'
assert pvc.spec.resources.requests['storage'] == '1Gi'
assert deployment.spec.strategy.type == 'Recreate'
assert deployment.spec.replicas == 1
assert deployment.spec.template.spec.automount_service_account_token is False
assert deployment.spec.template.spec.service_account_name == 'userbot-runtime'
assert deployment.spec.template.spec.volumes[1].host_path.path.endswith('/Downloads')
assert deployment.spec.template.spec.containers[0].resources.requests == {
'cpu': '80m',
'memory': '512Mi',
}
def test_collision_is_reported_before_auth() -> None:
kube = service()
kube.apps.read_namespaced_deployment.return_value = object()
with pytest.raises(PanelError) as error:
kube.assert_available('test-account')
assert error.value.status_code == 409
assert 'Deployment' in error.value.detail
def test_partial_provision_rolls_back_only_created_resources() -> None:
kube = service()
kube.ensure_prerequisites = Mock()
kube.assert_available = Mock()
kube.core.create_namespaced_persistent_volume_claim.side_effect = ApiException(status=500)
with pytest.raises(PanelError):
kube.provision(account(), 'SESSION')
kube.core.delete_namespaced_secret.assert_called_once_with(
'userbot-test-account-credentials',
'userbot',
)
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.apps.delete_namespaced_deployment.assert_not_called()
def test_provision_conflict_reports_existing_instance() -> None:
kube = service()
kube.assert_available = Mock()
kube.apps.create_namespaced_deployment.side_effect = ApiException(status=409)
with pytest.raises(PanelError) as error:
kube.provision(account(), 'SESSION')
assert error.value.status_code == 409
assert 'already exists' in error.value.detail
# Partial resources created before the 409 must be rolled back.
kube.core.delete_namespaced_secret.assert_called_once_with(
'userbot-test-account-credentials',
'userbot',
)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
'userbot-test-account-data',
'userbot',
)
def test_delete_retains_pvc_unless_explicitly_requested() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name='userbot-test-account',
annotations={
'userbot.forust.xyz/credentials-secret': 'credentials',
'userbot.forust.xyz/pvc': 'data',
},
)
)
kube._find_deployment = Mock(return_value=('userbot', deployment))
kube.delete('test-account', delete_data=False)
kube.apps.delete_namespaced_deployment.assert_called_once()
kube.core.delete_namespaced_secret.assert_called_once_with('credentials', 'userbot')
kube.core.delete_namespaced_persistent_volume_claim.assert_not_called()
kube.delete('test-account', delete_data=True)
kube.core.delete_namespaced_persistent_volume_claim.assert_called_once_with(
'data',
'userbot',
)
def test_legacy_delete_is_blocked() -> None:
kube = service()
deployment = SimpleNamespace(
metadata=SimpleNamespace(
name='forust-userbot-deployment',
annotations={'userbot.forust.xyz/legacy': 'true'},
)
)
kube._find_deployment = Mock(return_value=('default', deployment))
with pytest.raises(PanelError) as error:
kube.delete('forust', delete_data=False)
assert error.value.status_code == 409
def pod(*, ready: bool, phase: str = 'Running', reason: str | None = None):
waiting = SimpleNamespace(reason=reason) if reason else None
state = SimpleNamespace(waiting=waiting, terminated=None)
status = SimpleNamespace(
container_statuses=[
SimpleNamespace(ready=ready, restart_count=2, state=state),
],
phase=phase,
start_time=datetime.now(UTC),
)
return SimpleNamespace(
metadata=SimpleNamespace(name='pod-1', creation_timestamp=datetime.now(UTC)),
status=status,
)
def deployment(replicas: int = 1):
resources = SimpleNamespace(limits={'cpu': '300m', 'memory': '1536Mi'})
container = SimpleNamespace(image='userbot:latest', resources=resources)
template_spec = SimpleNamespace(containers=[container], volumes=[])
return SimpleNamespace(
metadata=SimpleNamespace(
name='userbot-test-account',
labels={'app.kubernetes.io/instance': 'test-account'},
annotations={},
creation_timestamp=datetime.now(UTC),
),
spec=SimpleNamespace(
replicas=replicas,
selector=SimpleNamespace(match_labels={'app': 'test'}),
template=SimpleNamespace(spec=template_spec),
),
status=SimpleNamespace(available_replicas=1 if replicas else 0),
)
@pytest.mark.parametrize(
('replicas', 'pod_value', 'expected'),
[
(0, None, 'stopped'),
(1, pod(ready=True), 'running'),
(1, pod(ready=False, phase='Pending'), 'pending'),
(1, pod(ready=False, reason='CrashLoopBackOff'), 'error'),
(1, pod(ready=False, phase='Failed'), 'error'),
],
)
def test_status_classification(replicas, pod_value, expected) -> None:
kube = service()
kube._pods_for_deployment = Mock(return_value=[pod_value] if pod_value else [])
kube._pvc_storage = Mock(return_value=None)
kube._pod_metrics = Mock(return_value=(None, None))
result = kube._summarize('userbot', deployment(replicas))
assert result.status == expected