mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 19:50:42 +08:00
267 lines
11 KiB
Python
267 lines
11 KiB
Python
"""Tests for ``fetch_dc`` helpers + DC-access-denied flow.
|
|
|
|
Complements ``test_fetch_dc_identity.py`` (which targets ``_resolve_identity``
|
|
specifically). This file lifts coverage of the other public helpers:
|
|
|
|
- ``DcAccessDenied`` exception carrier
|
|
- ``preflight_dc_access`` 401/403 classification + happy path
|
|
- ``_emit_dc_access_denied_preamble`` headless JSON shape
|
|
- ``_handle_dc_access_denied`` interactive (tty) vs headless branches
|
|
- ``_in_list`` SQL IN-fragment helper (dedup + NOT_SET filter)
|
|
- ``_extract_trace_ids`` runtime trace-id extraction (DMO + HTML-escaped)
|
|
- ``_preflight_templates`` SQL template existence check
|
|
|
|
All subprocess + DC-access boundaries are mocked.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from . import _bootstrap # noqa: F401 — sys.path setup
|
|
|
|
import fetch_dc # type: ignore
|
|
from dc import DCQueryError # type: ignore
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# DcAccessDenied — small carrier
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class DcAccessDeniedTests(unittest.TestCase):
|
|
|
|
def test_str_includes_reason_and_detail(self):
|
|
e = fetch_dc.DcAccessDenied("401", "Unauthorized")
|
|
self.assertEqual(e.reason, "401")
|
|
self.assertEqual(e.detail, "Unauthorized")
|
|
self.assertIn("401: Unauthorized", str(e))
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# preflight_dc_access — classify HTTP error code
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class PreflightDcAccessTests(unittest.TestCase):
|
|
|
|
def test_returns_url_and_token_on_success(self):
|
|
with mock.patch.object(
|
|
fetch_dc, "resolve_org",
|
|
return_value=("https://example.salesforce.com", "TOKEN"),
|
|
):
|
|
with mock.patch.object(fetch_dc, "post", return_value=[]):
|
|
url, token = fetch_dc.preflight_dc_access("sid", "my-org")
|
|
self.assertEqual(url, "https://example.salesforce.com")
|
|
self.assertEqual(token, "TOKEN")
|
|
|
|
def test_raises_dc_access_denied_on_401(self):
|
|
with mock.patch.object(
|
|
fetch_dc, "resolve_org",
|
|
return_value=("https://x", "T"),
|
|
):
|
|
with mock.patch.object(
|
|
fetch_dc, "post",
|
|
side_effect=DCQueryError("http=401 Unauthorized"),
|
|
):
|
|
with self.assertRaises(fetch_dc.DcAccessDenied) as ctx:
|
|
fetch_dc.preflight_dc_access("sid", "my-org")
|
|
self.assertEqual(ctx.exception.reason, "401")
|
|
|
|
def test_raises_dc_access_denied_on_403(self):
|
|
with mock.patch.object(
|
|
fetch_dc, "resolve_org",
|
|
return_value=("https://x", "T"),
|
|
):
|
|
with mock.patch.object(
|
|
fetch_dc, "post",
|
|
side_effect=DCQueryError("http=403 Forbidden"),
|
|
):
|
|
with self.assertRaises(fetch_dc.DcAccessDenied) as ctx:
|
|
fetch_dc.preflight_dc_access("sid", "my-org")
|
|
self.assertEqual(ctx.exception.reason, "403")
|
|
|
|
def test_raises_dc_access_denied_on_other_dc_error(self):
|
|
with mock.patch.object(
|
|
fetch_dc, "resolve_org",
|
|
return_value=("https://x", "T"),
|
|
):
|
|
with mock.patch.object(
|
|
fetch_dc, "post",
|
|
side_effect=DCQueryError("http=500 ISE"),
|
|
):
|
|
with self.assertRaises(fetch_dc.DcAccessDenied) as ctx:
|
|
fetch_dc.preflight_dc_access("sid", "my-org")
|
|
# 5xx falls into the catch-all dc_probe_failed bucket.
|
|
self.assertEqual(ctx.exception.reason, "dc_probe_failed")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# _emit_dc_access_denied_preamble — JSON shape
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class EmitPreambleTests(unittest.TestCase):
|
|
|
|
def test_emits_single_line_json_with_two_options(self):
|
|
buf = io.StringIO()
|
|
with mock.patch("sys.stdout", buf):
|
|
fetch_dc._emit_dc_access_denied_preamble("401", "Unauthorized")
|
|
printed = buf.getvalue().strip()
|
|
# One single-line JSON record
|
|
self.assertEqual(printed.count("\n"), 0)
|
|
payload = json.loads(printed)
|
|
self.assertEqual(payload["status"], "DC_ACCESS_DENIED")
|
|
self.assertEqual(payload["reason"], "401")
|
|
self.assertEqual(payload["detail"], "Unauthorized")
|
|
# Standalone d360 has retry + cancel only — no sibling-skill switch.
|
|
self.assertEqual(len(payload["options"]), 2)
|
|
codes = [o["code"] for o in payload["options"]]
|
|
self.assertEqual(codes, ["1", "2"])
|
|
actions = [o["action"] for o in payload["options"]]
|
|
self.assertEqual(actions, ["retry", "cancel"])
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# _handle_dc_access_denied — branches on tty vs not, + interactive choices
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class HandleDcAccessDeniedTests(unittest.TestCase):
|
|
|
|
def _exc(self) -> fetch_dc.DcAccessDenied:
|
|
return fetch_dc.DcAccessDenied("401", "Unauthorized")
|
|
|
|
def test_headless_emits_preamble_and_returns_exit_code(self):
|
|
with mock.patch.object(
|
|
fetch_dc, "_emit_dc_access_denied_preamble"
|
|
) as emit:
|
|
rc = fetch_dc._handle_dc_access_denied(
|
|
self._exc(), session_id="sid", is_tty=False,
|
|
)
|
|
emit.assert_called_once_with("401", "Unauthorized")
|
|
self.assertEqual(rc, fetch_dc.EXIT_DC_ACCESS_DENIED)
|
|
|
|
def test_interactive_choice_1_returns_exit_code(self):
|
|
# User picks "1" → caller signaled to retry.
|
|
with mock.patch.object(fetch_dc.sys, "stdin", io.StringIO("1\n")):
|
|
with mock.patch.object(fetch_dc, "_log"):
|
|
rc = fetch_dc._handle_dc_access_denied(
|
|
self._exc(), session_id="sid", is_tty=True,
|
|
)
|
|
self.assertEqual(rc, fetch_dc.EXIT_DC_ACCESS_DENIED)
|
|
|
|
def test_interactive_choice_2_returns_zero_cancel(self):
|
|
# Standalone d360: choice "2" is cancel (no sibling-skill switch).
|
|
with mock.patch.object(fetch_dc.sys, "stdin", io.StringIO("2\n")):
|
|
with mock.patch.object(fetch_dc, "_log"):
|
|
rc = fetch_dc._handle_dc_access_denied(
|
|
self._exc(), session_id="sid", is_tty=True,
|
|
)
|
|
self.assertEqual(rc, 0)
|
|
|
|
def test_interactive_unknown_choice_treated_as_cancel(self):
|
|
# "" / "9" / random → cancel.
|
|
with mock.patch.object(fetch_dc.sys, "stdin", io.StringIO("\n")):
|
|
with mock.patch.object(fetch_dc, "_log"):
|
|
rc = fetch_dc._handle_dc_access_denied(
|
|
self._exc(), session_id="sid", is_tty=True,
|
|
)
|
|
self.assertEqual(rc, 0)
|
|
|
|
def test_interactive_keyboard_interrupt_returns_zero(self):
|
|
# stdin.readline() raising KeyboardInterrupt → graceful 0.
|
|
fake_stdin = mock.MagicMock()
|
|
fake_stdin.readline.side_effect = KeyboardInterrupt
|
|
with mock.patch.object(fetch_dc.sys, "stdin", fake_stdin):
|
|
with mock.patch.object(fetch_dc, "_log"):
|
|
rc = fetch_dc._handle_dc_access_denied(
|
|
self._exc(), session_id="sid", is_tty=True,
|
|
)
|
|
self.assertEqual(rc, 0)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# _in_list — SQL IN-clause fragment builder
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class InListTests(unittest.TestCase):
|
|
|
|
def test_renders_quoted_csv_inside_parens(self):
|
|
self.assertEqual(fetch_dc._in_list(["a", "b", "c"]), "('a','b','c')")
|
|
|
|
def test_dedups_and_preserves_first_occurrence_order(self):
|
|
self.assertEqual(
|
|
fetch_dc._in_list(["a", "b", "a", "c", "b"]), "('a','b','c')"
|
|
)
|
|
|
|
def test_drops_empty_string(self):
|
|
self.assertEqual(fetch_dc._in_list(["a", "", "b"]), "('a','b')")
|
|
|
|
def test_drops_NOT_SET_token(self):
|
|
self.assertEqual(
|
|
fetch_dc._in_list(["a", "NOT_SET", "b"]), "('a','b')"
|
|
)
|
|
|
|
def test_empty_input_returns_empty_parens(self):
|
|
# All inputs filtered → "()". SQL won't accept this but that's the
|
|
# caller's concern — the helper itself is mechanical.
|
|
self.assertEqual(fetch_dc._in_list([]), "()")
|
|
self.assertEqual(fetch_dc._in_list(["", "NOT_SET"]), "()")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# _extract_trace_ids — DMO field + HTML-escaped fallback
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class ExtractTraceIdsTests(unittest.TestCase):
|
|
|
|
def test_uses_telemetry_trace_id_when_populated(self):
|
|
rows = [{"ssot__TelemetryTraceId__c": "abc123"}]
|
|
self.assertEqual(fetch_dc._extract_trace_ids(rows), ["abc123"])
|
|
|
|
def test_falls_back_to_attribute_text_internalTraceId(self):
|
|
# Simulate the HTML-escaped JSON-in-a-string shape DC stores.
|
|
rows = [{
|
|
"ssot__TelemetryTraceId__c": "",
|
|
"ssot__AttributeText__c": '"internalTraceId":"deadbeef"',
|
|
}]
|
|
self.assertEqual(fetch_dc._extract_trace_ids(rows), ["deadbeef"])
|
|
|
|
def test_dedupes_preserving_first_occurrence(self):
|
|
rows = [
|
|
{"ssot__TelemetryTraceId__c": "x"},
|
|
{"ssot__TelemetryTraceId__c": "y"},
|
|
{"ssot__TelemetryTraceId__c": "x"},
|
|
]
|
|
self.assertEqual(fetch_dc._extract_trace_ids(rows), ["x", "y"])
|
|
|
|
def test_drops_NOT_SET_token(self):
|
|
rows = [{"ssot__TelemetryTraceId__c": "NOT_SET"}]
|
|
self.assertEqual(fetch_dc._extract_trace_ids(rows), [])
|
|
|
|
def test_returns_empty_when_no_trace_id_anywhere(self):
|
|
rows = [{"ssot__TelemetryTraceId__c": "", "ssot__AttributeText__c": ""}]
|
|
self.assertEqual(fetch_dc._extract_trace_ids(rows), [])
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# _preflight_templates — SQL templates exist on disk
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class PreflightTemplatesTests(unittest.TestCase):
|
|
|
|
def test_succeeds_when_all_templates_present(self):
|
|
# All the production .sql files ship in assets/dc/. This call should
|
|
# complete cleanly under the in-tree install.
|
|
fetch_dc._preflight_templates() # raises if any missing
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|