- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
598 lines
28 KiB
Python
598 lines
28 KiB
Python
# #region Test.LoadTesting.Scope [C:3] [TYPE Module] [SEMANTICS test,load-testing,scope,pool,start]
|
|
# @BRIEF Verify _resolve_execution_scope (env missing / specs / query-model / failure), the
|
|
# bounded-pool drain with batched persistence, variation loading, breaker factory, phase
|
|
# persistence, status percent, reload guard, _now, and start_load_run task dispatch.
|
|
# @RELATION BINDS_TO -> [Plugin.LoadTesting.LoadTestingPlugin]
|
|
# @TEST_EDGE: env_missing -> scope None (lifecycle-only)
|
|
# @TEST_EDGE: query_model_failure -> scope None
|
|
# @TEST_EDGE: no_charts -> scope None
|
|
# @TEST_EDGE: no_items -> scope None
|
|
# @TEST_EDGE: run_disappeared -> _reload_run raises RuntimeError
|
|
# @TEST_EDGE: task_manager_failure -> start_load_run propagates
|
|
# @TEST_EDGE: zero_capacity -> pool capacity floors at 1
|
|
# @TEST_INVARIANT: execution_specs_acknowledged_bulk -> VERIFIED_BY: test_scope_specs_branch, test_pool_drains_records
|
|
import asyncio
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.models.load_testing import LoadRun, LoadVariation, RunPhase, RunStatus
|
|
from src.models.mapping import Base
|
|
from src.plugins.load_testing import (
|
|
_build_breaker,
|
|
_load_variations,
|
|
_now,
|
|
_persist_phase,
|
|
_reload_run,
|
|
_resolve_execution_scope,
|
|
_run_bounded_pool,
|
|
_status_percent,
|
|
start_load_run,
|
|
)
|
|
from src.services.load_testing.breaker import CircuitBreaker
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.Fakes [C:1] [TYPE Class]
|
|
# @BRIEF Hardcoded fakes: query-model charts, RunnerPool stand-in, task manager.
|
|
class _FakeChart:
|
|
def __init__(self, chart_id, dataset_id, metrics=None):
|
|
self.chart_id = chart_id
|
|
self.dataset_id = dataset_id
|
|
self.metrics = metrics or []
|
|
|
|
|
|
class _FakeQueryModel:
|
|
def __init__(self, charts):
|
|
self.charts = charts
|
|
|
|
|
|
class _FakeRunnerPool:
|
|
"""RunnerPool stand-in: records may be configured per test; on_result is awaited per item."""
|
|
|
|
def __init__(self, *, capacity, executor, env_capacity_semaphore, breaker, on_result):
|
|
self.capacity = capacity
|
|
self.executor = executor
|
|
self.env_capacity_semaphore = env_capacity_semaphore
|
|
self.breaker = breaker
|
|
self.on_result = on_result
|
|
self.records = []
|
|
self.results = []
|
|
|
|
async def run(self, items):
|
|
self.results = []
|
|
for i, it in enumerate(items):
|
|
record = dict(self.records[i]) if i < len(self.records) else {}
|
|
record.setdefault("execution_id", it.get("execution_id"))
|
|
if self.on_result is not None:
|
|
await self.on_result(record)
|
|
self.results.append(record)
|
|
return self.results
|
|
|
|
|
|
class _FakeTaskManager:
|
|
def __init__(self, task_id="task-1"):
|
|
self.task_id = task_id
|
|
self.calls = []
|
|
|
|
async def create_task(self, plugin_id, params):
|
|
self.calls.append((plugin_id, params))
|
|
return SimpleNamespace(id=self.task_id)
|
|
|
|
|
|
class _FailingTaskManager:
|
|
async def create_task(self, plugin_id, params):
|
|
raise RuntimeError("task manager down")
|
|
# #endregion Test.LoadTesting.Scope.Fakes
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.Db [C:1] [TYPE Function]
|
|
# @BRIEF Build an isolated FK-enforced in-memory SQLite session with all tables.
|
|
@pytest.fixture()
|
|
def db():
|
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
|
event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON"))
|
|
Base.metadata.create_all(bind=engine)
|
|
session = Session(bind=engine)
|
|
yield session
|
|
session.close()
|
|
engine.dispose()
|
|
# #endregion Test.LoadTesting.Scope.Db
|
|
|
|
|
|
def _make_run(db: Session, run_id: str = "run-1", **overrides) -> LoadRun:
|
|
fields = dict(
|
|
id=run_id,
|
|
environment_id="env-dev",
|
|
dashboard_id=400,
|
|
status=RunStatus.QUEUED,
|
|
phase=RunPhase.RAMP,
|
|
)
|
|
fields.update(overrides)
|
|
run = LoadRun(**fields)
|
|
db.add(run)
|
|
db.commit()
|
|
return run
|
|
|
|
|
|
def _env_cm(env=None):
|
|
cm = MagicMock()
|
|
cm.get_environment.return_value = env
|
|
return cm
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.ResolveScope [C:2] [TYPE Class]
|
|
# @BRIEF _resolve_execution_scope: env lookup, specs priority, query-model builds, degradation.
|
|
class TestResolveScope:
|
|
"""Verify execution-scope resolution contract."""
|
|
|
|
# #region Test.LoadTesting.Scope.TestEnvMissing [C:2] [TYPE Function]
|
|
# @BRIEF Unknown environment id degrades to None (lifecycle-only).
|
|
@pytest.mark.asyncio
|
|
async def test_scope_env_missing(self, db: Session):
|
|
run = _make_run(db)
|
|
with patch("src.dependencies.get_config_manager", return_value=_env_cm(None)):
|
|
assert await _resolve_execution_scope(db, run, []) is None
|
|
# #endregion Test.LoadTesting.Scope.TestEnvMissing
|
|
|
|
# #region Test.LoadTesting.Scope.TestSpecsBranch [C:2] [TYPE Function]
|
|
# @BRIEF Explicit execution_specs win: defaulted run/env/dashboard/chart fields, real breaker.
|
|
@pytest.mark.asyncio
|
|
async def test_scope_specs_branch(self, db: Session):
|
|
run = _make_run(db)
|
|
env = SimpleNamespace(id="env-dev")
|
|
specs = [
|
|
{"execution_id": "e1", "chart_id": 7, "variation_id": "v1", "filters_hash": "h1"},
|
|
{"execution_id": "e2", "dataset_id": 9},
|
|
{"execution_id": "e3"},
|
|
]
|
|
with patch("src.dependencies.get_config_manager", return_value=_env_cm(env)):
|
|
items, resolved_env, breaker = await _resolve_execution_scope(db, run, specs)
|
|
assert resolved_env is env
|
|
assert isinstance(breaker, CircuitBreaker)
|
|
assert len(items) == 3
|
|
assert items[0]["run_id"] == run.id
|
|
assert items[0]["environment_id"] == run.environment_id
|
|
assert items[0]["dashboard_id"] == run.dashboard_id
|
|
assert items[0]["chart_id"] == 7
|
|
assert items[1]["chart_id"] == 9 # dataset_id fallback
|
|
assert items[2]["chart_id"] == 0 # neither chart_id nor dataset_id
|
|
# run.total_executions untouched in the specs branch
|
|
assert db.get(LoadRun, run.id).total_executions == 0
|
|
# #endregion Test.LoadTesting.Scope.TestSpecsBranch
|
|
|
|
# #region Test.LoadTesting.Scope.TestQueryModelFailure [C:2] [TYPE Function]
|
|
# @BRIEF Inspection exception degrades to None (lifecycle-only).
|
|
@pytest.mark.asyncio
|
|
async def test_scope_query_model_failure(self, db: Session):
|
|
run = _make_run(db)
|
|
env = SimpleNamespace(id="env-dev")
|
|
with (
|
|
patch("src.dependencies.get_config_manager", return_value=_env_cm(env)),
|
|
patch("src.core.utils.client_registry.get_superset_client", new=AsyncMock()),
|
|
patch(
|
|
"src.services.dashboard_testing.query_model.inspect_dashboard_query_model",
|
|
new=AsyncMock(side_effect=RuntimeError("superset down")),
|
|
),
|
|
):
|
|
assert await _resolve_execution_scope(db, run, []) is None
|
|
# #endregion Test.LoadTesting.Scope.TestQueryModelFailure
|
|
|
|
# #region Test.LoadTesting.Scope.TestNoCharts [C:2] [TYPE Function]
|
|
# @BRIEF Query model with zero charts degrades to None.
|
|
@pytest.mark.asyncio
|
|
async def test_scope_no_charts(self, db: Session):
|
|
run = _make_run(db)
|
|
env = SimpleNamespace(id="env-dev")
|
|
with (
|
|
patch("src.dependencies.get_config_manager", return_value=_env_cm(env)),
|
|
patch("src.core.utils.client_registry.get_superset_client", new=AsyncMock()),
|
|
patch(
|
|
"src.services.dashboard_testing.query_model.inspect_dashboard_query_model",
|
|
new=AsyncMock(return_value=_FakeQueryModel(charts=[])),
|
|
),
|
|
):
|
|
assert await _resolve_execution_scope(db, run, []) is None
|
|
# #endregion Test.LoadTesting.Scope.TestNoCharts
|
|
|
|
# #region Test.LoadTesting.Scope.TestNoItems [C:2] [TYPE Function]
|
|
# @BRIEF build_execution_items returning nothing degrades to None.
|
|
@pytest.mark.asyncio
|
|
async def test_scope_no_items(self, db: Session):
|
|
run = _make_run(db)
|
|
env = SimpleNamespace(id="env-dev")
|
|
with (
|
|
patch("src.dependencies.get_config_manager", return_value=_env_cm(env)),
|
|
patch("src.core.utils.client_registry.get_superset_client", new=AsyncMock()),
|
|
patch(
|
|
"src.services.dashboard_testing.query_model.inspect_dashboard_query_model",
|
|
new=AsyncMock(return_value=_FakeQueryModel(charts=[_FakeChart(1, 10)])),
|
|
),
|
|
patch("src.services.load_testing.executor.build_execution_items", return_value=[]),
|
|
):
|
|
assert await _resolve_execution_scope(db, run, []) is None
|
|
# #endregion Test.LoadTesting.Scope.TestNoItems
|
|
|
|
# #region Test.LoadTesting.Scope.TestQueryModelBuildsItems [C:2] [TYPE Function]
|
|
# @BRIEF Query model + base variation build items; total_executions updated and committed.
|
|
@pytest.mark.asyncio
|
|
async def test_scope_query_model_builds_items(self, db: Session):
|
|
run = _make_run(db)
|
|
env = SimpleNamespace(id="env-dev")
|
|
built = [{"execution_id": "x1", "chart_id": 1}]
|
|
charts = [_FakeChart(1, 10, [SimpleNamespace(metric_name="count")]), _FakeChart(2, 20)]
|
|
with (
|
|
patch("src.dependencies.get_config_manager", return_value=_env_cm(env)),
|
|
patch("src.core.utils.client_registry.get_superset_client", new=AsyncMock()) as mock_client,
|
|
patch(
|
|
"src.services.dashboard_testing.query_model.inspect_dashboard_query_model",
|
|
new=AsyncMock(return_value=_FakeQueryModel(charts=charts)),
|
|
) as mock_inspect,
|
|
patch("src.services.load_testing.executor.build_execution_items", return_value=built) as mock_build,
|
|
):
|
|
items, resolved_env, breaker = await _resolve_execution_scope(db, run, [])
|
|
assert items == built
|
|
assert resolved_env is env
|
|
assert isinstance(breaker, CircuitBreaker)
|
|
assert db.get(LoadRun, run.id).total_executions == 1
|
|
mock_inspect.assert_awaited_once_with(mock_client.return_value, run.environment_id, run.dashboard_id)
|
|
expected_charts = [
|
|
{"chart_id": 1, "dataset_id": 10, "result_key": "count"},
|
|
{"chart_id": 2, "dataset_id": 20, "result_key": "count"},
|
|
]
|
|
mock_build.assert_called_once_with(
|
|
run=run, charts=expected_charts, variations=[{"variation_id": "base", "coordinates": {"filters": [], "viewport": {}, "role": "analyst"}}]
|
|
)
|
|
# #endregion Test.LoadTesting.Scope.TestQueryModelBuildsItems
|
|
|
|
# #region Test.LoadTesting.Scope.TestQueryModelWithVariations [C:2] [TYPE Function]
|
|
# @BRIEF Persisted variations feed build_execution_items instead of the base variation.
|
|
@pytest.mark.asyncio
|
|
async def test_scope_query_model_with_variations(self, db: Session):
|
|
run = _make_run(db)
|
|
db.add_all(
|
|
[
|
|
LoadVariation(run_id=run.id, variation_id="var-1", filters=[{"region": "north"}], viewport={"w": 1366}, role="analyst", time_range={"since": "7 days ago"}),
|
|
LoadVariation(run_id=run.id, variation_id="var-2", filters=None, viewport=None, role="exec", time_range=None),
|
|
]
|
|
)
|
|
db.commit()
|
|
env = SimpleNamespace(id="env-dev")
|
|
with (
|
|
patch("src.dependencies.get_config_manager", return_value=_env_cm(env)),
|
|
patch("src.core.utils.client_registry.get_superset_client", new=AsyncMock()),
|
|
patch(
|
|
"src.services.dashboard_testing.query_model.inspect_dashboard_query_model",
|
|
new=AsyncMock(return_value=_FakeQueryModel(charts=[_FakeChart(1, 10)])),
|
|
),
|
|
patch("src.services.load_testing.executor.build_execution_items", return_value=[{"execution_id": "x1"}]) as mock_build,
|
|
):
|
|
await _resolve_execution_scope(db, run, [])
|
|
variations = mock_build.call_args.kwargs["variations"]
|
|
assert variations[0]["variation_id"] == "var-1"
|
|
assert variations[0]["coordinates"]["filters"] == [{"region": "north"}]
|
|
assert variations[1]["variation_id"] == "var-2"
|
|
assert variations[1]["coordinates"]["filters"] == [] # None -> []
|
|
assert variations[1]["coordinates"]["viewport"] == {} # None -> {}
|
|
# #endregion Test.LoadTesting.Scope.TestQueryModelWithVariations
|
|
# #endregion Test.LoadTesting.Scope.ResolveScope
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.BoundedPool [C:2] [TYPE Class]
|
|
# @BRIEF _run_bounded_pool drains items through the pool and persists enriched records.
|
|
class TestBoundedPool:
|
|
"""Verify pool drain + batched persistence contract."""
|
|
|
|
def _patch_pool(self, records):
|
|
holder: dict = {}
|
|
|
|
def _make_pool(**kwargs):
|
|
pool = _FakeRunnerPool(**kwargs)
|
|
pool.records = records
|
|
holder["pool"] = pool
|
|
return pool
|
|
|
|
patchers = [
|
|
patch("src.services.load_testing.runner_pool.RunnerPool", side_effect=_make_pool),
|
|
patch("src.services.load_testing.persistence.write_load_executions"),
|
|
]
|
|
return patchers, holder
|
|
|
|
# #region Test.LoadTesting.Scope.TestPoolDrainsRecords [C:2] [TYPE Function]
|
|
# @BRIEF Records are enriched from the by_id map; breaker records each outcome.
|
|
@pytest.mark.asyncio
|
|
async def test_pool_drains_records(self, db: Session):
|
|
run = _make_run(db, effective_concurrency=3)
|
|
items = [
|
|
{"execution_id": "exec-1", "chart_id": 7, "variation_id": "v1", "filters_hash": "h1"},
|
|
{"execution_id": "exec-2", "chart_id": 8, "variation_id": "v2", "filters_hash": "h2"},
|
|
]
|
|
breaker = CircuitBreaker()
|
|
records = [
|
|
{"execution_id": "exec-1", "outcome": "error", "upstream_latency_ms": 5},
|
|
{"execution_id": "unknown", "outcome": "success"}, # unknown id -> item defaults; missing latency -> 0
|
|
]
|
|
patchers, holder = self._patch_pool(records)
|
|
with patchers[0], patchers[1] as mock_write:
|
|
await _run_bounded_pool(db, run, items, breaker)
|
|
|
|
fake_pool = holder["pool"]
|
|
assert fake_pool.capacity == 3
|
|
assert fake_pool.env_capacity_semaphore is None
|
|
first, second = mock_write.call_args_list
|
|
assert first.args[0] is db
|
|
record1 = first.args[1][0]
|
|
assert record1["run_id"] == run.id
|
|
assert record1["chart_id"] == 7
|
|
assert record1["variation_id"] == "v1"
|
|
assert record1["filters_hash"] == "h1"
|
|
assert record1["worker_id"] == "0"
|
|
record2 = second.args[1][0]
|
|
assert record2["chart_id"] == 0
|
|
assert record2["variation_id"] == "base"
|
|
assert record2["filters_hash"] == ""
|
|
# breaker saw both outcomes
|
|
assert list(breaker._outcomes) == [True, False]
|
|
assert breaker.tripped is False # 2 samples < min_samples
|
|
# #endregion Test.LoadTesting.Scope.TestPoolDrainsRecords
|
|
|
|
# #region Test.LoadTesting.Scope.TestPoolWithoutBreaker [C:2] [TYPE Function]
|
|
# @BRIEF breaker=None is tolerated: record() skipped, persistence still runs.
|
|
@pytest.mark.asyncio
|
|
async def test_pool_without_breaker(self, db: Session):
|
|
run = _make_run(db)
|
|
items = [{"execution_id": "exec-1", "chart_id": 7, "variation_id": "v1", "filters_hash": "h1"}]
|
|
records = [{"outcome": "success", "upstream_latency_ms": 3}]
|
|
patchers, holder = self._patch_pool(records)
|
|
with patchers[0], patchers[1] as mock_write:
|
|
await _run_bounded_pool(db, run, items, None)
|
|
assert holder["pool"].breaker is None
|
|
mock_write.assert_called_once()
|
|
# #endregion Test.LoadTesting.Scope.TestPoolWithoutBreaker
|
|
|
|
# #region Test.LoadTesting.Scope.TestPoolCapacityFloor [C:2] [TYPE Function]
|
|
# @BRIEF Zero/None effective_concurrency floors capacity at 1.
|
|
@pytest.mark.asyncio
|
|
async def test_pool_capacity_floor(self, db: Session):
|
|
run = _make_run(db, effective_concurrency=0)
|
|
items = [{"execution_id": "exec-1"}]
|
|
patchers, holder = self._patch_pool([])
|
|
with patchers[0], patchers[1]:
|
|
await _run_bounded_pool(db, run, items, None)
|
|
assert holder["pool"].capacity == 1
|
|
# #endregion Test.LoadTesting.Scope.TestPoolCapacityFloor
|
|
|
|
# #region Test.LoadTesting.Scope.TestPoolTripsBreaker [C:2] [TYPE Function]
|
|
# @BRIEF Breaker reaches tripped after enough error records (persist_result drives it).
|
|
@pytest.mark.asyncio
|
|
async def test_pool_trips_breaker(self, db: Session):
|
|
run = _make_run(db)
|
|
items = [{"execution_id": f"e{i}", "chart_id": 1, "variation_id": "v", "filters_hash": "h"} for i in range(21)]
|
|
breaker = CircuitBreaker()
|
|
records = [{"outcome": "error", "upstream_latency_ms": 99}] * 21
|
|
patchers, _ = self._patch_pool(records)
|
|
with patchers[0], patchers[1]:
|
|
await _run_bounded_pool(db, run, items, breaker)
|
|
assert breaker.tripped is True
|
|
assert breaker.trigger_metric == "error_rate"
|
|
# #endregion Test.LoadTesting.Scope.TestPoolTripsBreaker
|
|
# #endregion Test.LoadTesting.Scope.BoundedPool
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.Variations [C:2] [TYPE Class]
|
|
# @BRIEF _load_variations: persisted rows mapped to coordinate dicts, else base variation.
|
|
class TestLoadVariations:
|
|
"""Verify variation loading contract."""
|
|
|
|
# #region Test.LoadTesting.Scope.TestRowsMapped [C:2] [TYPE Function]
|
|
# @BRIEF Persisted rows map filters/viewport with None fallbacks; ordered by variation_id.
|
|
def test_rows_mapped(self, db: Session):
|
|
_make_run(db, run_id="run-v")
|
|
db.add_all(
|
|
[
|
|
LoadVariation(run_id="run-v", variation_id="var-2", filters=None, viewport=None, role="exec", time_range=None),
|
|
LoadVariation(run_id="run-v", variation_id="var-1", filters=[{"region": "north"}], viewport={"w": 1366}, role="analyst", time_range={"since": "7 days ago"}),
|
|
]
|
|
)
|
|
db.commit()
|
|
rows = _load_variations(db, db.get(LoadRun, "run-v"))
|
|
assert [r["variation_id"] for r in rows] == ["var-1", "var-2"]
|
|
assert rows[0]["coordinates"] == {"filters": [{"region": "north"}], "viewport": {"w": 1366}, "role": "analyst", "time_range": {"since": "7 days ago"}}
|
|
assert rows[1]["coordinates"] == {"filters": [], "viewport": {}, "role": "exec", "time_range": None}
|
|
# #endregion Test.LoadTesting.Scope.TestRowsMapped
|
|
|
|
# #region Test.LoadTesting.Scope.TestNoRowsBaseVariation [C:2] [TYPE Function]
|
|
# @BRIEF No persisted rows -> single base variation for an analyst.
|
|
def test_no_rows_base_variation(self, db: Session):
|
|
run = _make_run(db, run_id="run-nov")
|
|
rows = _load_variations(db, run)
|
|
assert rows == [{"variation_id": "base", "coordinates": {"filters": [], "viewport": {}, "role": "analyst"}}]
|
|
# #endregion Test.LoadTesting.Scope.TestNoRowsBaseVariation
|
|
# #endregion Test.LoadTesting.Scope.Variations
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.BreakerFactory [C:2] [TYPE Class]
|
|
# @BRIEF _build_breaker returns a fresh, untripped run-level breaker.
|
|
class TestBreakerFactory:
|
|
# #region Test.LoadTesting.Scope.TestBuildBreaker [C:2] [TYPE Function]
|
|
# @BRIEF Fresh CircuitBreaker with default thresholds and closed state.
|
|
def test_build_breaker(self):
|
|
breaker = _build_breaker()
|
|
assert isinstance(breaker, CircuitBreaker)
|
|
assert breaker.tripped is False
|
|
assert breaker.trigger_metric is None
|
|
# #endregion Test.LoadTesting.Scope.TestBuildBreaker
|
|
# #endregion Test.LoadTesting.Scope.BreakerFactory
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.PersistPhase [C:2] [TYPE Class]
|
|
# @BRIEF _persist_phase transitions, timestamps started_at once, emits aggregate progress.
|
|
class TestPersistPhase:
|
|
# #region Test.LoadTesting.Scope.TestStartsRunAndCommits [C:2] [TYPE Function]
|
|
# @BRIEF started_at is set on first transition and committed.
|
|
def test_starts_run_and_commits(self, db: Session):
|
|
run = _make_run(db, run_id="run-p1", status=RunStatus.QUEUED)
|
|
_persist_phase(db, run, RunStatus.RAMPING, RunPhase.RAMP, None, "Ramping load run")
|
|
fresh = db.get(LoadRun, "run-p1")
|
|
assert fresh.status == RunStatus.RAMPING
|
|
assert fresh.phase == RunPhase.RAMP
|
|
assert fresh.started_at is not None
|
|
# #endregion Test.LoadTesting.Scope.TestStartsRunAndCommits
|
|
|
|
# #region Test.LoadTesting.Scope.TestKeepsStartedAt [C:2] [TYPE Function]
|
|
# @BRIEF A second transition keeps the original started_at.
|
|
def test_keeps_started_at(self, db: Session):
|
|
run = _make_run(db, run_id="run-p2", status=RunStatus.RAMPING)
|
|
run.started_at = datetime(2024, 1, 1)
|
|
db.commit()
|
|
_persist_phase(db, run, RunStatus.STEADY, RunPhase.STEADY, None, "Steady")
|
|
assert db.get(LoadRun, "run-p2").started_at == datetime(2024, 1, 1)
|
|
# #endregion Test.LoadTesting.Scope.TestKeepsStartedAt
|
|
|
|
# #region Test.LoadTesting.Scope.TestProgressPercent [C:2] [TYPE Function]
|
|
# @BRIEF context present -> progress emitted with the phase percent.
|
|
def test_progress_percent(self, db: Session):
|
|
run = _make_run(db, run_id="run-p3", status=RunStatus.QUEUED)
|
|
ctx = SimpleNamespace(logger=MagicMock())
|
|
_persist_phase(db, run, RunStatus.STEADY, RunPhase.STEADY, ctx, "Steady load phase")
|
|
ctx.logger.progress.assert_called_once_with("Steady load phase", percent=60)
|
|
# #endregion Test.LoadTesting.Scope.TestProgressPercent
|
|
|
|
# #region Test.LoadTesting.Scope.TestProgressFailureSuppressed [C:2] [TYPE Function]
|
|
# @BRIEF A throwing progress sink is suppressed — persistence still succeeds.
|
|
def test_progress_failure_suppressed(self, db: Session):
|
|
run = _make_run(db, run_id="run-p4", status=RunStatus.QUEUED)
|
|
ctx = SimpleNamespace(logger=MagicMock())
|
|
ctx.logger.progress.side_effect = RuntimeError("sink down")
|
|
_persist_phase(db, run, RunStatus.DRAINING, RunPhase.DRAIN, ctx, "Draining")
|
|
assert db.get(LoadRun, "run-p4").phase == RunPhase.DRAIN
|
|
# #endregion Test.LoadTesting.Scope.TestProgressFailureSuppressed
|
|
# #endregion Test.LoadTesting.Scope.PersistPhase
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.StatusPercent [C:2] [TYPE Class]
|
|
# @BRIEF _status_percent maps the three non-terminal phases; unknown -> 0.
|
|
class TestStatusPercent:
|
|
# #region Test.LoadTesting.Scope.TestKnownAndUnknown [C:2] [TYPE Function]
|
|
# @BRIEF ramping=25, steady=60, draining=90, unknown=0.
|
|
def test_known_and_unknown(self):
|
|
assert _status_percent("ramping") == 25
|
|
assert _status_percent("steady") == 60
|
|
assert _status_percent("draining") == 90
|
|
assert _status_percent("queued") == 0
|
|
# #endregion Test.LoadTesting.Scope.TestKnownAndUnknown
|
|
# #endregion Test.LoadTesting.Scope.StatusPercent
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.ReloadRun [C:2] [TYPE Class]
|
|
# @BRIEF _reload_run re-fetches after rollback; raises when the row vanished.
|
|
class TestReloadRun:
|
|
# #region Test.LoadTesting.Scope.TestReloadFound [C:2] [TYPE Function]
|
|
# @BRIEF Existing row is re-fetched with committed state.
|
|
def test_reload_found(self, db: Session):
|
|
_make_run(db, run_id="run-r1", status=RunStatus.RAMPING)
|
|
db.commit()
|
|
reloaded = _reload_run(db, "run-r1")
|
|
assert reloaded.id == "run-r1"
|
|
assert reloaded.status == RunStatus.RAMPING
|
|
# #endregion Test.LoadTesting.Scope.TestReloadFound
|
|
|
|
# #region Test.LoadTesting.Scope.TestReloadMissing [C:2] [TYPE Function]
|
|
# @BRIEF Missing row raises RuntimeError describing the vanished run.
|
|
def test_reload_missing(self, db: Session):
|
|
with pytest.raises(RuntimeError, match="disappeared during execution"):
|
|
_reload_run(db, "run-ghost")
|
|
# #endregion Test.LoadTesting.Scope.TestReloadMissing
|
|
# #endregion Test.LoadTesting.Scope.ReloadRun
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.Now [C:2] [TYPE Class]
|
|
# @BRIEF _now returns a tz-aware UTC datetime.
|
|
class TestNow:
|
|
# #region Test.LoadTesting.Scope.TestNowUtc [C:2] [TYPE Function]
|
|
# @BRIEF Returned datetime carries a tzinfo.
|
|
def test_now_utc(self):
|
|
now = _now()
|
|
assert isinstance(now, datetime)
|
|
assert now.tzinfo is not None
|
|
# #endregion Test.LoadTesting.Scope.TestNowUtc
|
|
# #endregion Test.LoadTesting.Scope.Now
|
|
|
|
|
|
# #region Test.LoadTesting.Scope.StartLoadRun [C:2] [TYPE Class]
|
|
# @BRIEF start_load_run persists the run and dispatches exactly one TaskManager task.
|
|
class TestStartLoadRun:
|
|
"""Verify the R1 one-task-per-run dispatch contract."""
|
|
|
|
# #region Test.LoadTesting.Scope.TestStartDispatchesTask [C:2] [TYPE Function]
|
|
# @BRIEF Run row persisted with queued status and task_id bound to the created task.
|
|
@pytest.mark.asyncio
|
|
async def test_start_dispatches_task(self, db: Session):
|
|
from src.models.load_testing import LoadProfile
|
|
|
|
db.add(LoadProfile(id="prof-1", dashboard_id=400, environment_id="env-dev", revision=1,
|
|
concurrency_requested=4, execution_mode="iterations"))
|
|
db.commit()
|
|
tm = _FakeTaskManager(task_id="task-1")
|
|
run = await start_load_run(
|
|
db, tm,
|
|
environment_id="env-dev", dashboard_id=400,
|
|
profile_id="prof-1", profile_revision=2,
|
|
effective_concurrency=4, matrix_seed=3,
|
|
theoretical_variations=10, selected_variations=5, total_executions=20,
|
|
blast_radius_fingerprint="fp1", approval_gate_id="gate-1",
|
|
created_by="tester", extra_params={"hold_seconds": 1},
|
|
)
|
|
assert run.id is not None
|
|
assert run.status == RunStatus.QUEUED
|
|
assert run.phase == RunPhase.RAMP
|
|
assert run.task_id == "task-1"
|
|
assert run.profile_id == "prof-1"
|
|
assert run.profile_revision == 2
|
|
assert run.effective_concurrency == 4
|
|
assert run.created_by == "tester"
|
|
assert tm.calls == [("load_testing", {"load_run_id": run.id, "hold_seconds": 1})]
|
|
assert db.get(LoadRun, run.id).task_id == "task-1"
|
|
# #endregion Test.LoadTesting.Scope.TestStartDispatchesTask
|
|
|
|
# #region Test.LoadTesting.Scope.TestStartCustomIdNoExtras [C:2] [TYPE Function]
|
|
# @BRIEF Provided load_run_id is honored; no extra_params -> params carry only load_run_id.
|
|
@pytest.mark.asyncio
|
|
async def test_start_custom_id_no_extras(self, db: Session):
|
|
tm = _FakeTaskManager()
|
|
run = await start_load_run(
|
|
db, tm,
|
|
environment_id="env-dev", dashboard_id=401,
|
|
load_run_id="run-custom",
|
|
)
|
|
assert run.id == "run-custom"
|
|
assert tm.calls == [("load_testing", {"load_run_id": "run-custom"})]
|
|
# #endregion Test.LoadTesting.Scope.TestStartCustomIdNoExtras
|
|
|
|
# #region Test.LoadTesting.Scope.TestStartDefaults [C:2] [TYPE Function]
|
|
# @BRIEF Defaults: profile_revision=1, effective_concurrency=1, created_by=system.
|
|
@pytest.mark.asyncio
|
|
async def test_start_defaults(self, db: Session):
|
|
tm = _FakeTaskManager()
|
|
run = await start_load_run(db, tm, environment_id="env-dev", dashboard_id=402)
|
|
assert run.profile_revision == 1
|
|
assert run.effective_concurrency == 1
|
|
assert run.created_by == "system"
|
|
assert run.approval_gate_id is None
|
|
# #endregion Test.LoadTesting.Scope.TestStartDefaults
|
|
|
|
# #region Test.LoadTesting.Scope.TestStartTaskFailure [C:2] [TYPE Function]
|
|
# @BRIEF TaskManager failure propagates out of start_load_run.
|
|
@pytest.mark.asyncio
|
|
async def test_start_task_failure(self, db: Session):
|
|
with pytest.raises(RuntimeError, match="task manager down"):
|
|
await start_load_run(db, _FailingTaskManager(), environment_id="env-dev", dashboard_id=403)
|
|
# #endregion Test.LoadTesting.Scope.TestStartTaskFailure
|
|
# #endregion Test.LoadTesting.Scope.StartLoadRun
|
|
|
|
|
|
# #endregion Test.LoadTesting.Scope
|