"""Unit tests for runner helpers (seed lookup + query_after retry wrapper).""" import os import sys import threading import time from typing import Optional from urllib.parse import unquote_plus import pytest sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import runner @pytest.fixture def r(): instance = runner.TestRunner(os.path.dirname(os.path.abspath(__file__))) instance.seed_data = { "account": [ {"Id": "acc1", "Name": "Test Acct", "PLG_BD_Pool__c": True} ] } return instance def test_seed_lookup_two_segments_returns_id(r: runner.TestRunner) -> None: assert r._lookup_seed_value("Account_0") == "acc1" def test_seed_lookup_three_segments_returns_field(r: runner.TestRunner) -> None: assert r._lookup_seed_value("Account_0_Name") == "Test Acct" def test_seed_lookup_underscore_field_name(r: runner.TestRunner) -> None: # PLG_BD_Pool__c contains underscores — they must rejoin, not split out. assert r._lookup_seed_value("Account_0_PLG_BD_Pool__c") is True def test_seed_lookup_case_insensitive_fallback(r: runner.TestRunner) -> None: assert r._lookup_seed_value("Account_0_NAME") == "Test Acct" def test_seed_lookup_missing_field_returns_none(r: runner.TestRunner) -> None: assert r._lookup_seed_value("Account_0_Missing") is None def test_seed_lookup_bad_index_returns_none(r: runner.TestRunner) -> None: assert r._lookup_seed_value("Account_PLG") is None def test_build_case_variables_adds_billing_country_code(r: runner.TestRunner) -> None: variables = r._build_case_variables({"id": "R-572", "scene": "quote"}) assert variables["billing_country_code"] in runner.TestRunner.BILLING_COUNTRY_CODE_POOL assert len(variables["billing_country_code"]) == 2 def test_query_after_delay_zero_does_not_sleep(r: runner.TestRunner) -> None: """Explicit delay_ms=0 must NOT coerce to default 2000.""" calls: list[float] = [] def ff(qa: dict, cid: Optional[str] = None): calls.append(time.time()) return False, "no", None r._query_after_once = ff # type: ignore[assignment] case = {"query_after": {"retry": 2, "delay_ms": 0}} t0 = time.time() r._execute_query_after(case, "x") elapsed = time.time() - t0 assert elapsed < 0.3 assert len(calls) == 3 def test_query_after_retry_zero_explicit(r: runner.TestRunner) -> None: calls: list[float] = [] def ff(qa: dict, cid: Optional[str] = None): calls.append(time.time()) return False, "no", None r._query_after_once = ff # type: ignore[assignment] case = {"query_after": {"retry": 0, "delay_ms": 1000}} r._execute_query_after(case, "x") assert len(calls) == 1 def test_query_after_delay_null_falls_back_to_default(r: runner.TestRunner) -> None: calls: list[float] = [] def ff(qa: dict, cid: Optional[str] = None): calls.append(time.time()) return False, "no", None r._query_after_once = ff # type: ignore[assignment] case = {"query_after": {"retry": 1, "delay_ms": None}} t0 = time.time() r._execute_query_after(case, "x") elapsed = time.time() - t0 # retry=1 → 2 attempts → 1 sleep of default 2000ms assert elapsed >= 1.9 assert len(calls) == 2 def test_query_after_absent_block_passes(r: runner.TestRunner) -> None: case: dict = {} passed, _, _ = r._execute_query_after(case, "x") assert passed is True def test_parallel_scene_execution_uses_isolated_runners(tmp_path, monkeypatch) -> None: r = runner.TestRunner(str(tmp_path)) seen_runner_ids: dict[str, int] = {} lock = threading.Lock() def fake_setup_admin_user() -> None: return None def fake_setup_seed_data() -> None: return None def fake_load_cases(scene=None, case_id=None): return [ {"id": "A-001", "name": "scene a", "scene": "alpha"}, {"id": "B-001", "name": "scene b", "scene": "beta"}, ] def fake_run_case(self, case): # noqa: ANN001 with lock: seen_runner_ids[case["scene"]] = id(self) return {"id": case["id"], "name": case["name"], "status": "PASS", "scene": case["scene"]} monkeypatch.setattr(r, "_setup_admin_user", fake_setup_admin_user) monkeypatch.setattr(r, "_setup_seed_data", fake_setup_seed_data) monkeypatch.setattr(r, "_load_cases", fake_load_cases) monkeypatch.setattr(runner.TestRunner, "run_case", fake_run_case) report = r.run(parallel=2) assert report["errors"] == 0 assert report["failed"] == 0 assert set(seen_runner_ids.keys()) == {"alpha", "beta"} assert len(set(seen_runner_ids.values())) == 2 def test_query_after_retry_until_pass(r: runner.TestRunner) -> None: attempts = {"n": 0} def ff(qa: dict, cid: Optional[str] = None): attempts["n"] += 1 if attempts["n"] < 3: return False, "Field Foo should not be null", {"Foo": None} return True, "OK", {"Foo": "Bar"} r._query_after_once = ff # type: ignore[assignment] case = { "query_after": { "retry": 5, "delay_ms": 10, "object": "X", "assertions": [ {"field": "Foo", "operator": "is_not_null", "value": True} ], } } passed, _, _ = r._execute_query_after(case, "x") assert passed is True assert attempts["n"] == 3 def test_query_after_non_id_supports_raw_response_assertions_sort_and_limit( r: runner.TestRunner, ) -> None: captured: dict[str, str] = {} def fake_get(endpoint: str): captured["endpoint"] = endpoint return 200, { "totalSize": 2, "records": [ { "Id": "cms-sent", "CampaignId": "cmp-001", "Label": "Sent", "SortOrder": 1, "IsDefault": True, "HasResponded": False, }, { "Id": "cms-responded", "CampaignId": "cmp-001", "Label": "Responded", "SortOrder": 2, "IsDefault": False, "HasResponded": True, }, ], } r.client.get = fake_get # type: ignore[assignment] passed, message, response = r._query_after_once( { "object": "CampaignMemberStatus", "id_field": "CampaignId", "record_id": "cmp-001", "select_fields": [ "CampaignId", "Label", "SortOrder", "IsDefault", "HasResponded", ], "sort": [{"field": "SortOrder", "direction": "ASC"}], "limit": 2, "response_assertions": [ {"field": "totalSize", "operator": "eq", "value": 2}, {"field": "records.0.CampaignId", "operator": "eq", "value": "cmp-001"}, {"field": "records.0.Label", "operator": "eq", "value": "Sent"}, {"field": "records.1.CampaignId", "operator": "eq", "value": "cmp-001"}, {"field": "records.1.Label", "operator": "eq", "value": "Responded"}, ], } ) assert passed is True assert message == "OK" assert response["totalSize"] == 2 assert ( "SELECT Id, CampaignId, Label, SortOrder, IsDefault, HasResponded " "FROM CampaignMemberStatus WHERE CampaignId='cmp-001' ORDER BY SortOrder ASC LIMIT 2" ) in unquote_plus(captured["endpoint"]) def test_query_after_non_id_preserves_default_query_shape(r: runner.TestRunner) -> None: calls: list[str] = [] class StubClient: def get(self, path: str): calls.append(path) return 200, { "totalSize": 1, "records": [ { "Id": "cms-1", "CampaignId": "cmp-1", "Label": "Responded", } ], } r.client = StubClient() passed, message, _ = r._query_after_once( { "object": "CampaignMemberStatus", "id_field": "CampaignId", "record_id": "cmp-1", "assertions": [ {"field": "Label", "operator": "eq", "value": "Responded"}, ], } ) assert passed is True assert message == "OK" assert len(calls) == 1 soql = unquote_plus(calls[0].split("?q=", 1)[1]) assert soql == ( "SELECT Id, CampaignId, Label FROM CampaignMemberStatus " "WHERE CampaignId='cmp-1' ORDER BY CreatedDate DESC LIMIT 1" ) def test_load_cases_by_id_resolves_unique_opportunity_and_order_line_cases( r: runner.TestRunner, ) -> None: order_line_cases = r._load_cases(case_id="OLI-002") assert len(order_line_cases) == 1 assert order_line_cases[0]["id"] == "OLI-002" assert order_line_cases[0]["scene"] == "order_line" opportunity_line_cases = r._load_cases(case_id="OPLI-002") assert len(opportunity_line_cases) == 1 assert opportunity_line_cases[0]["id"] == "OPLI-002" assert opportunity_line_cases[0]["scene"] == "opportunity_line" def test_assert_body_contains_accepts_string_and_list(r: runner.TestRunner) -> None: response = {"error": "already approved", "nested": {"message": "approval denied"}} passed, message = r._assert_body_contains(response, "already approved") assert passed is True assert message == "OK" passed, message = r._assert_body_contains( response, ["already approved", "approval denied"], ) assert passed is True assert message == "OK" def test_build_request_steps_preserves_legacy_approval_sequence( r: runner.TestRunner, ) -> None: case = { "id": "APR-TEST", "name": "approval flow", "method": "POST", "endpoint": "/submit", "payload": {"actionType": "Submit"}, "expects": {"status_code": 200}, "approval_step": { "endpoint": "/approve", "payload": {"actionType": "Approve"}, "expects": {"status_code": 403}, }, "retry_approval": { "endpoint": "/approve", "payload": {"actionType": "Approve"}, "expects": {"status_code": 400}, }, } steps = r._build_request_steps(case) assert [step["endpoint"] for step in steps] == ["/submit", "/approve", "/approve"] assert [step["name"] for step in steps] == [ "approval flow", "approval_step", "retry_approval", ] def test_get_http_client_caches_step_level_auth_clients( r: runner.TestRunner, monkeypatch: pytest.MonkeyPatch, ) -> None: created: list[tuple[str, str]] = [] class StubClient: def __init__(self, base_url=None, username=None, password=None): created.append((username, password)) monkeypatch.setattr(runner.api, "NexusAPIClient", StubClient) client_one = r._get_http_client({"username": "sales@test.com", "password": "Sales123!"}) client_two = r._get_http_client({"username": "sales@test.com", "password": "Sales123!"}) assert client_one is client_two assert created == [("sales@test.com", "Sales123!")] def test_close_closes_default_and_cached_http_sessions(r: runner.TestRunner) -> None: closed: list[str] = [] class StubSession: def __init__(self, name: str) -> None: self.name = name def close(self) -> None: closed.append(self.name) class StubClient: def __init__(self, name: str) -> None: self.session = StubSession(name) r.client = StubClient("default") r._client_cache = { ("sales@test.com", "Sales123!"): StubClient("sales"), ("ops@test.com", "Ops123!"): StubClient("ops"), } r.close() assert closed == ["default", "sales", "ops"] assert r._client_cache == {} def test_run_scene_cases_case_filter_overrides_skip(r: runner.TestRunner) -> None: executed: list[str] = [] def fake_run_case(case: dict) -> dict: executed.append(case["id"]) return { "id": case["id"], "name": case["name"], "status": "PASS", "scene": case["scene"], "expected": {}, "actual": {}, } r.run_case = fake_run_case # type: ignore[assignment] results = r._run_scene_cases( "quote", [{"id": "R-572", "name": "quote projection", "scene": "quote", "skip": True}], case_filter="R-572", ) assert executed == ["R-572"] assert results[0]["status"] == "PASS" def test_run_scene_worker_creates_isolated_runner_instances( monkeypatch: pytest.MonkeyPatch, ) -> None: created: list["StubRunner"] = [] class StubRunner: def __init__(self, base_path: str = None) -> None: self.base_path = base_path self.seed_data = None self.variables = {} self.closed = False created.append(self) def _run_scene_cases(self, scene_name: str, scene_cases: list[dict], case_filter=None) -> list[dict]: self.variables["scene"] = scene_name return [{ "id": scene_cases[0]["id"], "status": "PASS", "scene": scene_name, "worker_scene": self.variables["scene"], }] def close(self) -> None: self.closed = True monkeypatch.setattr(runner, "TestRunner", StubRunner) result_quote = runner._run_scene_worker( "quote", [{"id": "R-572", "scene": "quote"}], "/tmp/e2e", {"quote": []}, ) result_order = runner._run_scene_worker( "order", [{"id": "ORD-003", "scene": "order"}], "/tmp/e2e", {"order": []}, ) assert len(created) == 2 assert created[0] is not created[1] assert all(instance.closed for instance in created) assert result_quote[0]["worker_scene"] == "quote" assert result_order[0]["worker_scene"] == "order" def test_run_scene_worker_passes_run_deadline( monkeypatch: pytest.MonkeyPatch, ) -> None: seen_deadlines: list[float | None] = [] class StubRunner: def __init__(self, base_path: str = None) -> None: self.base_path = base_path self.seed_data = None self._run_deadline = None def _run_scene_cases(self, scene_name: str, scene_cases: list[dict], case_filter=None) -> list[dict]: seen_deadlines.append(self._run_deadline) return [{"id": scene_cases[0]["id"], "status": "PASS", "scene": scene_name}] def close(self) -> None: return None monkeypatch.setattr(runner, "TestRunner", StubRunner) deadline = time.monotonic() + 123 runner._run_scene_worker( "quote", [{"id": "R-572", "scene": "quote"}], "/tmp/e2e", {"quote": []}, run_deadline=deadline, ) assert seen_deadlines == [deadline] def test_execute_request_steps_runs_legacy_follow_up_requests( r: runner.TestRunner, ) -> None: calls: list[str] = [] def fake_execute_step(step: dict, phase: str): calls.append(step["name"]) return True, "OK", { "name": step["name"], "type": "http", "status_code": 200, "response": {"success": True}, } r._execute_step = fake_execute_step # type: ignore[assignment] passed, message, details, status_code, response, created_id = r._execute_request_steps( { "id": "APR-TEST", "name": "approval flow", "method": "POST", "endpoint": "/submit", "approval_step": {"endpoint": "/approve"}, "retry_approval": {"endpoint": "/approve-again"}, } ) assert passed is True assert message == "OK" assert [detail["name"] for detail in details] == [ "approval flow", "approval_step", "retry_approval", ] assert calls == ["approval flow", "approval_step", "retry_approval"] assert status_code == 200 assert response == {"success": True} assert created_id is None def test_run_scene_cases_marks_remaining_cases_error_after_soft_timeout( r: runner.TestRunner, ) -> None: executed: list[str] = [] def fake_run_case(case: dict) -> dict: executed.append(case["id"]) r._run_deadline = time.monotonic() - 1 return { "id": case["id"], "name": case["name"], "status": "PASS", "scene": case["scene"], "expected": {}, "actual": {}, } r.run_case = fake_run_case # type: ignore[assignment] r._run_deadline = time.monotonic() + 30 results = r._run_scene_cases( "quote", [ {"id": "R-572", "name": "quote projection", "scene": "quote"}, {"id": "R-573", "name": "quote sync", "scene": "quote"}, ], ) assert executed == ["R-572"] assert results[0]["status"] == "PASS" assert results[1]["status"] == "ERROR" assert "Run timeout exceeded" in results[1]["error"]