mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-08-01 04:16:17 +08:00
459 lines
19 KiB
Python
459 lines
19 KiB
Python
"""Tests for ``discover_sessions`` — DC-only session picker.
|
|
|
|
Covers:
|
|
- ``parse_time_expr`` pure str → TimeRange (12 supported expressions)
|
|
- ``compose_sql`` pure args → SQL string (conditional JOINs / WHEREs)
|
|
- ``fetch_agent_names`` DC follow-up call (mocks ``post``)
|
|
- ``render_picker`` pure rows → markdown
|
|
- ``main`` argv → exit code (mocks ``resolve_org`` + ``post``)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest import mock
|
|
|
|
from . import _bootstrap # noqa: F401 — sys.path setup
|
|
|
|
import discover_sessions # type: ignore
|
|
from dc import DCQueryError # type: ignore
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# parse_time_expr — pure
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class ParseTimeExprTests(unittest.TestCase):
|
|
|
|
def test_default_none_returns_last_24_hours(self):
|
|
tr = discover_sessions.parse_time_expr(None, "UTC")
|
|
delta = tr.end_utc - tr.start_utc
|
|
self.assertEqual(delta, timedelta(hours=24))
|
|
self.assertIn("default", tr.expr)
|
|
|
|
def test_last_n_hours(self):
|
|
tr = discover_sessions.parse_time_expr("last 2 hours", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(hours=2))
|
|
self.assertEqual(tr.expr, "last 2 hours")
|
|
|
|
def test_last_n_minutes(self):
|
|
tr = discover_sessions.parse_time_expr("last 30 minutes", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(minutes=30))
|
|
|
|
def test_last_n_days(self):
|
|
tr = discover_sessions.parse_time_expr("last 3 days", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(days=3))
|
|
|
|
def test_last_n_bare_defaults_to_hours(self):
|
|
# Spec: "last 10" is ambiguous; the regex treats unit-less as hours.
|
|
tr = discover_sessions.parse_time_expr("last 10", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(hours=10))
|
|
|
|
def test_today_uses_local_tz_calendar_day(self):
|
|
tr = discover_sessions.parse_time_expr("today", "UTC")
|
|
# End - start should be exactly 24 hours.
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(days=1))
|
|
|
|
def test_yesterday_window_precedes_today(self):
|
|
today = discover_sessions.parse_time_expr("today", "UTC")
|
|
yest = discover_sessions.parse_time_expr("yesterday", "UTC")
|
|
self.assertEqual(yest.end_utc, today.start_utc)
|
|
self.assertEqual(yest.end_utc - yest.start_utc, timedelta(days=1))
|
|
|
|
def test_bare_iso_date(self):
|
|
tr = discover_sessions.parse_time_expr("2026-04-22", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(days=1))
|
|
self.assertEqual(tr.start_utc.year, 2026)
|
|
self.assertEqual(tr.start_utc.month, 4)
|
|
self.assertEqual(tr.start_utc.day, 22)
|
|
|
|
def test_iso_date_range_inclusive(self):
|
|
# "2026-04-22 to 2026-04-25" → 4 calendar days (22, 23, 24, 25).
|
|
tr = discover_sessions.parse_time_expr("2026-04-22 to 2026-04-25", "UTC")
|
|
self.assertEqual(tr.end_utc - tr.start_utc, timedelta(days=4))
|
|
|
|
def test_explicit_iso_datetime(self):
|
|
tr = discover_sessions.parse_time_expr(
|
|
"2026-04-22T10:00:00Z", "UTC"
|
|
)
|
|
self.assertEqual(tr.start_utc.year, 2026)
|
|
self.assertIn("since", tr.expr)
|
|
|
|
def test_unparseable_expression_raises(self):
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
discover_sessions.parse_time_expr("nope", "UTC")
|
|
self.assertIn("cannot parse", str(ctx.exception))
|
|
|
|
def test_unknown_tz_raises(self):
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
discover_sessions.parse_time_expr("today", "Mars/Olympus")
|
|
self.assertIn("unknown IANA timezone", str(ctx.exception))
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# compose_sql — pure
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _basic_tr() -> discover_sessions.TimeRange:
|
|
return discover_sessions.TimeRange(
|
|
start_utc=datetime(2026, 4, 22, 0, 0, 0, tzinfo=timezone.utc),
|
|
end_utc=datetime(2026, 4, 22, 23, 0, 0, tzinfo=timezone.utc),
|
|
expr="test", tz_name="UTC",
|
|
)
|
|
|
|
|
|
class ComposeSqlTests(unittest.TestCase):
|
|
|
|
def test_no_filters_skips_joins_and_uses_no_distinct(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep=None, limit=20,
|
|
)
|
|
# Strip SQL comments so the JOIN/DISTINCT mentions in the template
|
|
# docblock don't fool the naive substring check.
|
|
executable = "\n".join(
|
|
line for line in sql.splitlines() if not line.strip().startswith("--")
|
|
)
|
|
self.assertNotIn("JOIN ", executable)
|
|
self.assertNotIn("DISTINCT", executable)
|
|
self.assertIn("ssot__StartTimestamp__c >= '2026-04-22T00:00:00.000Z'", executable)
|
|
self.assertIn("ssot__StartTimestamp__c < '2026-04-22T23:00:00.000Z'", executable)
|
|
|
|
def test_agent_filter_adds_participant_join(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent="MyAgent", channel=None,
|
|
outcome=None, grep=None, limit=20,
|
|
)
|
|
self.assertIn("ssot__AiAgentSessionParticipant__dlm", sql)
|
|
self.assertIn("p.ssot__AiAgentApiName__c = 'MyAgent'", sql)
|
|
self.assertIn("DISTINCT", sql)
|
|
|
|
def test_messaging_channel_maps_to_scrt2_string(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel="Messaging",
|
|
outcome=None, grep=None, limit=20,
|
|
)
|
|
self.assertIn("'SCRT2 - EmbeddedMessaging'", sql)
|
|
|
|
def test_unknown_channel_passes_through(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel="Custom_Channel",
|
|
outcome=None, grep=None, limit=20,
|
|
)
|
|
self.assertIn("'Custom_Channel'", sql)
|
|
|
|
def test_outcome_filter_added(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome="ESCALATED", grep=None, limit=20,
|
|
)
|
|
self.assertIn("ssot__AiAgentSessionEndType__c = 'ESCALATED'", sql)
|
|
|
|
def test_grep_adds_interaction_message_joins(self):
|
|
# Default --grep is case-insensitive (§16d) — both sides are
|
|
# wrapped in LOWER(...). The case-sensitive variant is covered
|
|
# by test_grep_case_sensitive_opt_in below.
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep="refund", limit=20,
|
|
)
|
|
self.assertIn("ssot__AiAgentInteraction__dlm", sql)
|
|
self.assertIn("ssot__AiAgentInteractionMessage__dlm", sql)
|
|
self.assertIn("LOWER(m.ssot__ContentText__c) LIKE LOWER('%refund%')", sql)
|
|
self.assertIn("ESCAPE '!'", sql)
|
|
|
|
def test_grep_case_sensitive_opt_in(self):
|
|
# --grep-case-sensitive bypasses the LOWER() wrap — restores the
|
|
# exact-match shape for the rare user who wants it.
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep="Refund", grep_case_sensitive=True, limit=20,
|
|
)
|
|
self.assertIn("m.ssot__ContentText__c LIKE '%Refund%'", sql)
|
|
self.assertNotIn("LOWER(", sql)
|
|
self.assertIn("ESCAPE '!'", sql)
|
|
|
|
def test_grep_escapes_like_wildcards(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep="100%_off", limit=20,
|
|
)
|
|
# Both `%` and `_` get the `!` escape prefix
|
|
self.assertIn("100!%!_off", sql)
|
|
|
|
def test_grep_escapes_single_quotes(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep="O'Brien", limit=20,
|
|
)
|
|
# Single quote doubled per SQL escaping rule
|
|
self.assertIn("O''Brien", sql)
|
|
|
|
def test_limit_substituted(self):
|
|
sql = discover_sessions.compose_sql(
|
|
tr=_basic_tr(), agent=None, channel=None,
|
|
outcome=None, grep=None, limit=42,
|
|
)
|
|
self.assertIn("42", sql)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# fetch_agent_names — mocks dc.post
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class FetchAgentNamesTests(unittest.TestCase):
|
|
|
|
def test_empty_session_ids_returns_empty_dict_no_post(self):
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
out = discover_sessions.fetch_agent_names(
|
|
[], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {})
|
|
p.assert_not_called()
|
|
|
|
def test_returns_dict_keyed_by_session_id(self):
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.return_value = [
|
|
{
|
|
"ssot__AiAgentSessionId__c": "sid1",
|
|
"ssot__AiAgentApiName__c": "AgentA",
|
|
},
|
|
{
|
|
"ssot__AiAgentSessionId__c": "sid2",
|
|
"ssot__AiAgentApiName__c": "AgentB",
|
|
},
|
|
]
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1", "sid2"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {"sid1": "AgentA", "sid2": "AgentB"})
|
|
|
|
def test_skips_rows_with_empty_agent_name(self):
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.return_value = [
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": ""},
|
|
{"ssot__AiAgentSessionId__c": "sid2", "ssot__AiAgentApiName__c": "AgentB"},
|
|
]
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1", "sid2"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {"sid2": "AgentB"})
|
|
|
|
def test_dcqueryerror_returns_empty_dict_non_fatal(self):
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.side_effect = DCQueryError("boom")
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {})
|
|
|
|
def test_uses_user_row_name_when_agent_row_is_not_set(self):
|
|
# MyAgent-shape session: AGENT row carries 'NOT_SET',
|
|
# USER row carries the real api_name. Old code filtered to
|
|
# role=AGENT and would return 'NOT_SET' (or nothing). New code
|
|
# is role-agnostic and recovers the api_name from the USER row.
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.return_value = [
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": "NOT_SET"},
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": "MyAgent"},
|
|
]
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {"sid1": "MyAgent"})
|
|
|
|
def test_drops_session_when_every_row_is_not_set(self):
|
|
# No usable api_name anywhere — picker should show '—' for the
|
|
# session, which is more honest than displaying the literal
|
|
# 'NOT_SET' string. Implementation contract: missing key in dict.
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.return_value = [
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": "NOT_SET"},
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": ""},
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": None},
|
|
]
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertNotIn("sid1", out)
|
|
self.assertEqual(out, {})
|
|
|
|
def test_picks_lexicographic_first_on_handoff_session(self):
|
|
# Multi-agent handoff: two distinct AGENT api_names appear on
|
|
# different rows for the same session. Dominant-agent rule
|
|
# (sorted(...)[0]) matches the policy used by fetch_dc — every
|
|
# writer agrees on the session's namesake.
|
|
with mock.patch.object(discover_sessions, "post") as p:
|
|
p.return_value = [
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": "ZAgent"},
|
|
{"ssot__AiAgentSessionId__c": "sid1", "ssot__AiAgentApiName__c": "AAgent"},
|
|
]
|
|
out = discover_sessions.fetch_agent_names(
|
|
["sid1"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertEqual(out, {"sid1": "AAgent"})
|
|
|
|
def test_sql_does_not_filter_by_role(self):
|
|
# Lock the no-role-filter contract — if a future "tighten this
|
|
# back up" refactor re-adds the AGENT filter, MyAgent
|
|
# sessions silently regress to '—' or 'NOT_SET' in the picker.
|
|
captured = {}
|
|
|
|
def fake_post(sql, *_a, **_k):
|
|
captured["sql"] = sql
|
|
return []
|
|
|
|
with mock.patch.object(discover_sessions, "post", side_effect=fake_post):
|
|
discover_sessions.fetch_agent_names(
|
|
["sid1"], instance_url="https://x", token="t",
|
|
)
|
|
self.assertNotIn("AiAgentSessionParticipantRole", captured["sql"])
|
|
self.assertNotIn("'AGENT'", captured["sql"])
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# render_picker — pure
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def _picker_filters(**overrides) -> dict:
|
|
base = {"agent": None, "channel": None, "outcome": None, "grep": None}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
class RenderPickerTests(unittest.TestCase):
|
|
|
|
def test_zero_rows_emits_widen_hint_with_composed_sql(self):
|
|
out = discover_sessions.render_picker(
|
|
rows=[], agent_by_sid={}, org="my-org",
|
|
tr=_basic_tr(), filters=_picker_filters(),
|
|
composed_sql="SELECT 1",
|
|
)
|
|
self.assertIn("No sessions matched", out)
|
|
self.assertIn("```sql\nSELECT 1\n```", out)
|
|
self.assertIn("Try widening", out)
|
|
|
|
def test_renders_markdown_table_with_one_row(self):
|
|
rows = [{
|
|
"ssot__Id__c": "019dface-0000-7000-8000-000000000001",
|
|
"ssot__StartTimestamp__c": "2026-04-22T10:00:00Z",
|
|
"ssot__EndTimestamp__c": "2026-04-22T10:01:30Z",
|
|
"ssot__AiAgentChannelType__c": "SCRT2 - EmbeddedMessaging",
|
|
"ssot__AiAgentSessionEndType__c": "USER_ENDED",
|
|
}]
|
|
out = discover_sessions.render_picker(
|
|
rows=rows, agent_by_sid={"019dface-0000-7000-8000-000000000001": "AgentA"},
|
|
org="my-org", tr=_basic_tr(),
|
|
filters=_picker_filters(), composed_sql="",
|
|
)
|
|
self.assertIn("Found **1** session", out)
|
|
self.assertIn("`019dface-0000-7000-8000-000000000001`", out)
|
|
self.assertIn("AgentA", out)
|
|
# Reverse-aliased channel
|
|
self.assertIn("Messaging", out)
|
|
# Duration formatter
|
|
self.assertIn("1m 30s", out)
|
|
self.assertIn("USER_ENDED", out)
|
|
|
|
def test_handles_session_without_known_agent_with_dash(self):
|
|
rows = [{
|
|
"ssot__Id__c": "sid1",
|
|
"ssot__StartTimestamp__c": "2026-04-22T10:00:00Z",
|
|
"ssot__EndTimestamp__c": "2026-04-22T10:00:30Z",
|
|
"ssot__AiAgentChannelType__c": "Builder",
|
|
"ssot__AiAgentSessionEndType__c": None,
|
|
}]
|
|
out = discover_sessions.render_picker(
|
|
rows=rows, agent_by_sid={}, org="my-org",
|
|
tr=_basic_tr(), filters=_picker_filters(), composed_sql="",
|
|
)
|
|
self.assertIn("| — |", out) # agent + outcome both render as "—"
|
|
self.assertIn("30s", out)
|
|
|
|
def test_filter_bits_appear_in_header(self):
|
|
rows = [{
|
|
"ssot__Id__c": "sid1",
|
|
"ssot__StartTimestamp__c": "2026-04-22T10:00:00Z",
|
|
"ssot__EndTimestamp__c": "2026-04-22T10:00:30Z",
|
|
"ssot__AiAgentChannelType__c": "Builder",
|
|
"ssot__AiAgentSessionEndType__c": "ESCALATED",
|
|
}]
|
|
out = discover_sessions.render_picker(
|
|
rows=rows, agent_by_sid={}, org="my-org", tr=_basic_tr(),
|
|
filters=_picker_filters(agent="MyAgent", outcome="ESCALATED"),
|
|
composed_sql="",
|
|
)
|
|
self.assertIn("agent=MyAgent", out)
|
|
self.assertIn("outcome=ESCALATED", out)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# main — argv-driven exit codes
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
class MainTests(unittest.TestCase):
|
|
|
|
def _argv(self, *extra: str) -> list[str]:
|
|
# `--org` is required; everything else has a default or is optional.
|
|
return ["discover_sessions.py", "--org", "my-org", *extra]
|
|
|
|
def test_main_exit_zero_when_rows_returned(self):
|
|
with mock.patch.object(discover_sessions.sys, "argv", self._argv()):
|
|
with mock.patch.object(
|
|
discover_sessions, "resolve_org",
|
|
return_value=("https://example.salesforce.com", "TOKEN"),
|
|
):
|
|
with mock.patch.object(
|
|
discover_sessions, "post",
|
|
return_value=[{
|
|
"ssot__Id__c": "sid1",
|
|
"ssot__StartTimestamp__c": "2026-04-22T00:00:00Z",
|
|
"ssot__EndTimestamp__c": "2026-04-22T00:01:00Z",
|
|
"ssot__AiAgentChannelType__c": "Builder",
|
|
"ssot__AiAgentSessionEndType__c": "USER_ENDED",
|
|
}],
|
|
):
|
|
rc = discover_sessions.main()
|
|
self.assertEqual(rc, 0)
|
|
|
|
def test_main_exit_two_when_zero_rows(self):
|
|
with mock.patch.object(discover_sessions.sys, "argv", self._argv()):
|
|
with mock.patch.object(
|
|
discover_sessions, "resolve_org",
|
|
return_value=("https://x", "T"),
|
|
):
|
|
with mock.patch.object(discover_sessions, "post", return_value=[]):
|
|
rc = discover_sessions.main()
|
|
self.assertEqual(rc, 2)
|
|
|
|
def test_main_exit_one_when_dc_query_fails(self):
|
|
with mock.patch.object(discover_sessions.sys, "argv", self._argv()):
|
|
with mock.patch.object(
|
|
discover_sessions, "resolve_org",
|
|
return_value=("https://x", "T"),
|
|
):
|
|
with mock.patch.object(
|
|
discover_sessions, "post",
|
|
side_effect=DCQueryError("HTTP 500"),
|
|
):
|
|
rc = discover_sessions.main()
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_main_rejects_zero_or_negative_limit(self):
|
|
with mock.patch.object(
|
|
discover_sessions.sys, "argv",
|
|
self._argv("--limit", "0"),
|
|
):
|
|
with self.assertRaises(SystemExit) as ctx:
|
|
discover_sessions.main()
|
|
self.assertIn("--limit must be >= 1", str(ctx.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|