mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-30 00:27:11 +09:00
Plan 75 §G7. The suite's hand-rolled stubs were entrenching the very duplication the plan removes: _FakeProc pasted byte-identical in four modules, ~25 fake_run closures re-implementing argv dispatch, and zero tests patching the wrappers — every stub targeted raw subprocess inside one service module, so each new service test recreated the raw seam. tests/subprocess_stub.py is that seam. Tests script argv → result; when G1 moves services onto run_checked() the retarget happens here once instead of in twelve files. Two properties are deliberate: - An unscripted command raises instead of inventing returncode=0. It is a BaseException for the same reason SbinPathError is — services wrap subprocess in `except Exception`, so an AssertionError would be eaten by exactly the handlers the kit exists to test. - Stubbed execs are still sbin-guarded. Patching subprocess removes the §B2 runtime guard, so the kit re-applies it; popen_guard grows a public guard_argv() as the one door for that check. Matching normalises away a sudo prefix and argv[0] absolutisation, so one scripted rule matches on a root box, a sudo box and a dev box alike. Mutation-checked: removing the guard call fails the guard test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
301 lines
9.8 KiB
Python
301 lines
9.8 KiB
Python
"""Tests for the on-demand server speed test (service tiers + API).
|
|
|
|
All network access is stubbed — the suite stays offline.
|
|
"""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from app.services import speed_test_service as sts_module
|
|
from app.services.speed_test_service import (
|
|
LAST_RESULT_KEY,
|
|
SPEEDTEST_JOB_KIND,
|
|
SpeedTestService,
|
|
)
|
|
|
|
# Registers blueprints / url rules on the app fixture. Flask cannot unregister
|
|
# those, so these tests need a private app rather than the session-wide one
|
|
# (plan 64 Phase 1).
|
|
pytestmark = pytest.mark.fresh_app
|
|
|
|
|
|
# The shared stub kit (plan 75 §G7) — this class was pasted
|
|
# byte-identical in four test modules.
|
|
from subprocess_stub import FakeProc as _FakeProc
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, body=b'', status=200):
|
|
self._body = body
|
|
self.status_code = status
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise sts_module.requests.HTTPError(f'HTTP {self.status_code}')
|
|
|
|
def iter_content(self, chunk_size=65536):
|
|
for i in range(0, len(self._body), chunk_size):
|
|
yield self._body[i:i + chunk_size]
|
|
|
|
|
|
class _FakeSocket:
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def no_cli(monkeypatch):
|
|
"""Pretend no speedtest binary exists — forces the pure-Python tier."""
|
|
monkeypatch.setattr(sts_module, 'is_command_available', lambda cmd: False)
|
|
|
|
|
|
# ── fallback tier ────────────────────────────────────────────────────────────
|
|
|
|
def test_fallback_full_success(no_cli, monkeypatch):
|
|
monkeypatch.setattr(
|
|
sts_module.socket, 'create_connection',
|
|
lambda addr, timeout=None: _FakeSocket(),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.requests, 'get',
|
|
lambda url, timeout=None, stream=False: _FakeResponse(b'x' * 1_000_000),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.requests, 'post',
|
|
lambda url, data=None, timeout=None: _FakeResponse(),
|
|
)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is True
|
|
assert result['method'] == 'fallback'
|
|
assert result['download_mbps'] > 0
|
|
assert result['upload_mbps'] > 0
|
|
assert result['latency_ms'] is not None and result['latency_ms'] >= 0
|
|
assert result['tested_at']
|
|
|
|
|
|
def test_fallback_upload_failure_is_best_effort(no_cli, monkeypatch):
|
|
monkeypatch.setattr(
|
|
sts_module.socket, 'create_connection',
|
|
lambda addr, timeout=None: _FakeSocket(),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.requests, 'get',
|
|
lambda url, timeout=None, stream=False: _FakeResponse(b'x' * 500_000),
|
|
)
|
|
|
|
def _post_fails(url, data=None, timeout=None):
|
|
raise sts_module.requests.ConnectionError('upload endpoint unreachable')
|
|
|
|
monkeypatch.setattr(sts_module.requests, 'post', _post_fails)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is True
|
|
assert result['download_mbps'] > 0
|
|
assert result['upload_mbps'] is None # best-effort
|
|
|
|
|
|
def test_fallback_total_failure_returns_error(no_cli, monkeypatch):
|
|
def _connect_fails(addr, timeout=None):
|
|
raise OSError('network down')
|
|
|
|
def _get_fails(url, timeout=None, stream=False):
|
|
raise sts_module.requests.ConnectionError('no route')
|
|
|
|
def _post_fails(url, data=None, timeout=None):
|
|
raise sts_module.requests.ConnectionError('no route')
|
|
|
|
monkeypatch.setattr(sts_module.socket, 'create_connection', _connect_fails)
|
|
monkeypatch.setattr(sts_module.requests, 'get', _get_fails)
|
|
monkeypatch.setattr(sts_module.requests, 'post', _post_fails)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is False
|
|
assert 'error' in result
|
|
|
|
|
|
# ── CLI tier ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_cli_ookla_json_parsed(monkeypatch):
|
|
monkeypatch.setattr(
|
|
sts_module, 'is_command_available', lambda cmd: cmd == 'speedtest'
|
|
)
|
|
ookla = json.dumps({
|
|
'download': {'bandwidth': 12_500_000}, # bytes/s -> 100 Mbps
|
|
'upload': {'bandwidth': 2_500_000}, # bytes/s -> 20 Mbps
|
|
'ping': {'latency': 8.42},
|
|
})
|
|
monkeypatch.setattr(
|
|
sts_module.subprocess, 'run',
|
|
lambda cmd, capture_output=True, text=True, timeout=None: _FakeProc(0, ookla),
|
|
)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is True
|
|
assert result['method'] == 'cli'
|
|
assert result['download_mbps'] == 100.0
|
|
assert result['upload_mbps'] == 20.0
|
|
assert result['latency_ms'] == 8.42
|
|
|
|
|
|
def test_cli_python_speedtest_cli_json_parsed(monkeypatch):
|
|
monkeypatch.setattr(
|
|
sts_module, 'is_command_available', lambda cmd: cmd == 'speedtest-cli'
|
|
)
|
|
legacy = json.dumps({
|
|
'download': 50_000_000.0, # bits/s -> 50 Mbps
|
|
'upload': 10_000_000.0, # bits/s -> 10 Mbps
|
|
'ping': 22.5,
|
|
})
|
|
monkeypatch.setattr(
|
|
sts_module.subprocess, 'run',
|
|
lambda cmd, capture_output=True, text=True, timeout=None: _FakeProc(0, legacy),
|
|
)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is True
|
|
assert result['method'] == 'cli'
|
|
assert result['download_mbps'] == 50.0
|
|
assert result['upload_mbps'] == 10.0
|
|
assert result['latency_ms'] == 22.5
|
|
|
|
|
|
def test_cli_failure_falls_back_to_python_tier(monkeypatch):
|
|
monkeypatch.setattr(
|
|
sts_module, 'is_command_available', lambda cmd: cmd == 'speedtest'
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.subprocess, 'run',
|
|
lambda cmd, capture_output=True, text=True, timeout=None: _FakeProc(1, '', 'boom'),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.socket, 'create_connection',
|
|
lambda addr, timeout=None: _FakeSocket(),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.requests, 'get',
|
|
lambda url, timeout=None, stream=False: _FakeResponse(b'x' * 100_000),
|
|
)
|
|
monkeypatch.setattr(
|
|
sts_module.requests, 'post',
|
|
lambda url, data=None, timeout=None: _FakeResponse(),
|
|
)
|
|
|
|
result = SpeedTestService.run_test()
|
|
|
|
assert result['success'] is True
|
|
assert result['method'] == 'fallback'
|
|
|
|
|
|
# ── job handler ──────────────────────────────────────────────────────────────
|
|
|
|
def test_job_handler_stores_last_result(app, monkeypatch):
|
|
from app.services.settings_service import SettingsService
|
|
|
|
fixed = {
|
|
'success': True, 'method': 'fallback', 'download_mbps': 42.0,
|
|
'upload_mbps': 7.5, 'latency_ms': 12.0, 'tested_at': '2026-07-03T00:00:00Z',
|
|
}
|
|
monkeypatch.setattr(SpeedTestService, 'run_test', classmethod(lambda cls: fixed))
|
|
|
|
class _JobStub:
|
|
def get_payload(self):
|
|
return {}
|
|
|
|
result = SpeedTestService.run_speed_test_job(_JobStub())
|
|
|
|
assert result == fixed
|
|
stored = SettingsService.get(LAST_RESULT_KEY)
|
|
assert json.loads(stored) == fixed
|
|
|
|
|
|
def test_register_jobs_registers_handler(app):
|
|
from app.jobs import registry
|
|
|
|
SpeedTestService.register_jobs()
|
|
assert registry.get(SPEEDTEST_JOB_KIND) is not None
|
|
|
|
|
|
# ── API ──────────────────────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def st_client(app):
|
|
"""Test client with the speedtest blueprint mounted (registration in
|
|
app/__init__.py is wired separately)."""
|
|
from app.api.speed_test import speedtest_bp
|
|
if 'speedtest' not in app.blueprints:
|
|
app.register_blueprint(speedtest_bp, url_prefix='/api/v1/speedtest')
|
|
return app.test_client()
|
|
|
|
|
|
def test_get_returns_last_result_and_running_flag(st_client, auth_headers, app):
|
|
from app.services.settings_service import SettingsService
|
|
|
|
fixed = {'success': True, 'method': 'cli', 'download_mbps': 90.0,
|
|
'upload_mbps': 30.0, 'latency_ms': 5.0,
|
|
'tested_at': '2026-07-03T01:00:00Z'}
|
|
SettingsService.set(LAST_RESULT_KEY, json.dumps(fixed))
|
|
|
|
resp = st_client.get('/api/v1/speedtest', headers=auth_headers)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data['last_result'] == fixed
|
|
assert data['running'] is False
|
|
assert data['job'] is None
|
|
|
|
|
|
def test_get_with_no_result_yet(st_client, auth_headers):
|
|
resp = st_client.get('/api/v1/speedtest', headers=auth_headers)
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data['last_result'] is None
|
|
assert data['running'] is False
|
|
|
|
|
|
def test_run_enqueues_job(st_client, auth_headers, monkeypatch):
|
|
from app.jobs.service import JobService
|
|
|
|
enqueued = []
|
|
|
|
class _FakeJob:
|
|
id = 'job-123'
|
|
|
|
def _fake_enqueue(kind, payload=None, max_attempts=3, **kwargs):
|
|
enqueued.append((kind, payload, max_attempts))
|
|
return _FakeJob()
|
|
|
|
monkeypatch.setattr(JobService, 'enqueue', staticmethod(_fake_enqueue))
|
|
|
|
resp = st_client.post('/api/v1/speedtest/run', headers=auth_headers)
|
|
|
|
assert resp.status_code == 202
|
|
assert resp.get_json() == {'job_id': 'job-123'}
|
|
assert enqueued == [(SPEEDTEST_JOB_KIND, {}, 1)]
|
|
|
|
|
|
def test_run_rejects_when_already_running(st_client, auth_headers, app):
|
|
from app import db
|
|
from app.jobs.models import Job
|
|
|
|
job = Job(kind=SPEEDTEST_JOB_KIND, status=Job.STATUS_RUNNING)
|
|
job.set_payload({})
|
|
db.session.add(job)
|
|
db.session.commit()
|
|
|
|
resp = st_client.post('/api/v1/speedtest/run', headers=auth_headers)
|
|
|
|
assert resp.status_code == 409
|
|
assert 'error' in resp.get_json()
|
|
|
|
|
|
def test_endpoints_require_auth(st_client):
|
|
assert st_client.get('/api/v1/speedtest').status_code == 401
|
|
assert st_client.post('/api/v1/speedtest/run').status_code == 401
|