apm-wo-analysis/tests/test_slack_post_handler.py

334 lines
12 KiB
Python
Raw Permalink Normal View History

"""Tests for the slack-post Lambda handler and slackio I/O helpers.
Handler is loaded via importlib to avoid the handler.py name collision between
the classifier and slack-post Lambdas (both are on the pythonpath and both are
named handler.py — bare ``import handler`` would be ambiguous/cached).
Monkeypatching strategy
-----------------------
The handler module shares the same ``slackio`` object that was imported during
module load (``sp_handler.slackio is slackio`` is True), so patching attributes
on the ``slackio`` module is sufficient.
Real ``blockkit`` is used (it is pure — no network, no AWS).
slackio cache isolation
-----------------------
``_creds`` and ``_dashboard_url`` are module-level globals that persist across
tests within the same process. Each slackio test resets them before running to
guarantee isolation.
"""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Shared pythonpath: slack_post dir must be on sys.path for slackio + blockkit.
# conftest.py sets the env vars; we rely on that here.
# ---------------------------------------------------------------------------
_SLACK_POST_DIR = Path(__file__).resolve().parents[1] / "lambdas" / "slack_post"
if str(_SLACK_POST_DIR) not in sys.path:
sys.path.insert(0, str(_SLACK_POST_DIR))
import slackio # noqa: E402
# ---------------------------------------------------------------------------
# Load the slack-post handler via importlib (avoids name collision with
# lambdas/classifier/handler.py which is also on pythonpath).
# ---------------------------------------------------------------------------
_HANDLER_PATH = _SLACK_POST_DIR / "handler.py"
_spec = importlib.util.spec_from_file_location("slack_post_handler", _HANDLER_PATH)
sp_handler = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sp_handler)
# ---------------------------------------------------------------------------
# Fake Slack WebClient
# ---------------------------------------------------------------------------
class _FakeClient:
"""Minimal WebClient stand-in that records chat_postMessage calls."""
def __init__(self):
self.calls: list[dict] = []
def chat_postMessage(self, **kwargs):
self.calls.append(kwargs)
return {"ok": True}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
SUMMARY_ZERO_THIRDS = {
"dt": "2026-05-28",
"classified_total": 100,
"blank_comment_rows": 5,
"escalation_total": 3,
"action_needed": 10,
"routine": 90,
"third_escalation_count": 0,
"category_counts": {"Schedule Confirmed": 50, "3rd Escalation": 0},
"top_sites": [],
"mismatches": [],
"generated_at": "2026-05-28T12:00:00Z",
}
SUMMARY_WITH_THIRDS = {
**SUMMARY_ZERO_THIRDS,
"third_escalation_count": 2,
"category_counts": {"3rd Escalation": 2, "Schedule Confirmed": 48},
}
DETAILS_WITH_THIRDS = [
{
"wo_number": "WO-001",
"category": "3rd Escalation",
"site": "ABQ5",
"wo_description": "Fix HVAC unit",
},
{
"wo_number": "WO-002",
"category": "3rd Escalation",
"site": "ACY9",
"wo_description": "Repair dock door",
},
{
"wo_number": "WO-003",
"category": "Schedule Confirmed", # should NOT appear in the alert
"site": "ABQ5",
"wo_description": "Routine PM",
},
]
# ---------------------------------------------------------------------------
# Section 1: handler._yesterday
# ---------------------------------------------------------------------------
class TestYesterday:
def test_yesterday_basic(self):
assert sp_handler._yesterday("2026-05-28") == "2026-05-27"
def test_yesterday_month_boundary(self):
assert sp_handler._yesterday("2026-06-01") == "2026-05-31"
def test_yesterday_year_boundary(self):
assert sp_handler._yesterday("2026-01-01") == "2025-12-31"
# ---------------------------------------------------------------------------
# Section 2: handler.handler — the main Lambda entry point
# ---------------------------------------------------------------------------
class TestHandler:
def _make_fake_client(self):
return _FakeClient()
def test_no_summary_returns_not_posted_and_does_not_call_slack(self, monkeypatch):
"""When read_meta_json returns None for summary.json, handler returns
posted=False and must NOT call chat_postMessage."""
fake_client = self._make_fake_client()
monkeypatch.setattr(slackio, "read_meta_json", lambda dt, name: None)
monkeypatch.setattr(slackio, "web_client", lambda: fake_client)
monkeypatch.setattr(slackio, "channel_id", lambda: "C12345")
monkeypatch.setattr(
slackio, "get_dashboard_url", lambda: "https://grafana.example.com"
)
result = sp_handler.handler({"dt": "2026-05-28"}, None)
assert result["posted"] is False
assert result["dt"] == "2026-05-28"
assert fake_client.calls == [], "chat_postMessage must not be called"
def test_summary_zero_thirds_posts_summary_only(self, monkeypatch):
"""When third_escalation_count == 0, exactly one chat_postMessage is made
(daily summary); the standalone alert is suppressed."""
fake_client = self._make_fake_client()
def fake_read(dt, name):
if name == "summary.json":
return SUMMARY_ZERO_THIRDS
return None
monkeypatch.setattr(slackio, "read_meta_json", fake_read)
monkeypatch.setattr(slackio, "web_client", lambda: fake_client)
monkeypatch.setattr(slackio, "channel_id", lambda: "C12345")
monkeypatch.setattr(
slackio, "get_dashboard_url", lambda: "https://grafana.example.com"
)
result = sp_handler.handler({"dt": "2026-05-28"}, None)
assert result["posted"] is True
assert result["summary"] is True
assert result["alert"] is False
assert len(fake_client.calls) == 1, "exactly one postMessage (summary)"
# The message text should reference today's dt.
assert "2026-05-28" in fake_client.calls[0]["text"]
def test_summary_with_thirds_posts_summary_and_alert(self, monkeypatch):
"""When third_escalation_count > 0, two chat_postMessage calls are made:
one for the daily summary and one for the 3rd-escalation alert."""
fake_client = self._make_fake_client()
def fake_read(dt, name):
if name == "summary.json":
return SUMMARY_WITH_THIRDS
if name == "details.json":
return DETAILS_WITH_THIRDS
return None
monkeypatch.setattr(slackio, "read_meta_json", fake_read)
monkeypatch.setattr(slackio, "web_client", lambda: fake_client)
monkeypatch.setattr(slackio, "channel_id", lambda: "C12345")
monkeypatch.setattr(
slackio, "get_dashboard_url", lambda: "https://grafana.example.com"
)
result = sp_handler.handler({"dt": "2026-05-28"}, None)
assert result["posted"] is True
assert result["summary"] is True
assert result["alert"] is True
assert len(fake_client.calls) == 2, "summary + alert = two postMessages"
def test_alert_filtered_to_3rd_escalation_only(self, monkeypatch):
"""The details.json rows fed to build_escalation_alert must be pre-filtered
to category == '3rd Escalation'. WO-003 (Schedule Confirmed) must not
appear in the alert message."""
fake_client = self._make_fake_client()
def fake_read(dt, name):
if name == "summary.json":
return SUMMARY_WITH_THIRDS
if name == "details.json":
return DETAILS_WITH_THIRDS
return None
monkeypatch.setattr(slackio, "read_meta_json", fake_read)
monkeypatch.setattr(slackio, "web_client", lambda: fake_client)
monkeypatch.setattr(slackio, "channel_id", lambda: "C12345")
monkeypatch.setattr(
slackio, "get_dashboard_url", lambda: "https://grafana.example.com"
)
sp_handler.handler({"dt": "2026-05-28"}, None)
# The alert is the second postMessage call.
assert len(fake_client.calls) == 2
alert_call = fake_client.calls[1]
alert_blocks = alert_call["blocks"]
# Serialise blocks to text for easy searching.
alert_text = json.dumps(alert_blocks)
# Both 3rd-escalation WOs must be present.
assert "WO-001" in alert_text
assert "WO-002" in alert_text
# The non-3rd WO must NOT appear in the alert.
assert "WO-003" not in alert_text
# ---------------------------------------------------------------------------
# Section 3: slackio unit tests
# ---------------------------------------------------------------------------
class TestSlackioReadMetaJson:
def test_returns_none_on_no_such_key(self, monkeypatch):
"""read_meta_json must return None (not raise) when S3 returns NoSuchKey."""
# Build a fake get_object that raises the real NoSuchKey exception.
NoSuchKey = slackio._s3.exceptions.NoSuchKey
def fake_get_object(Bucket, Key):
raise NoSuchKey(
{"Error": {"Code": "NoSuchKey", "Message": "not found"}},
"GetObject",
)
monkeypatch.setattr(slackio._s3, "get_object", fake_get_object)
result = slackio.read_meta_json("2026-05-28", "summary.json")
assert result is None
def test_returns_parsed_json_on_success(self, monkeypatch):
"""read_meta_json must parse and return the JSON body on a successful get."""
payload = {"classified_total": 42}
class _FakeBody:
def read(self):
return json.dumps(payload).encode("utf-8")
monkeypatch.setattr(
slackio._s3, "get_object", lambda Bucket, Key: {"Body": _FakeBody()}
)
result = slackio.read_meta_json("2026-05-28", "summary.json")
assert result == payload
class TestSlackioCredentialsCaching:
"""get_credentials() and get_dashboard_url() must cache their results and
only call the underlying boto3 client once per container lifetime."""
def setup_method(self):
# Reset module-level caches so each test starts from a cold state.
slackio._creds = None
slackio._dashboard_url = None
def test_get_credentials_calls_secrets_once(self, monkeypatch):
"""Calling get_credentials() twice must invoke get_secret_value exactly
once (the result is cached after the first call)."""
creds_payload = {
"botToken": "xoxb-test",
"signingSecret": "abc",
"channelId": "C99",
}
call_count = 0
def fake_get_secret(SecretId):
nonlocal call_count
call_count += 1
return {"SecretString": json.dumps(creds_payload)}
monkeypatch.setattr(slackio._secrets, "get_secret_value", fake_get_secret)
first = slackio.get_credentials()
second = slackio.get_credentials()
assert call_count == 1, "get_secret_value should be called only once"
assert first == creds_payload
assert second is first # same object from cache
def test_get_dashboard_url_calls_ssm_once(self, monkeypatch):
"""Calling get_dashboard_url() twice must invoke get_parameter exactly
once (the URL is cached after the first call)."""
expected_url = "https://grafana.example.com/d/abc"
call_count = 0
def fake_get_parameter(Name):
nonlocal call_count
call_count += 1
return {"Parameter": {"Value": expected_url}}
monkeypatch.setattr(slackio._ssm, "get_parameter", fake_get_parameter)
first = slackio.get_dashboard_url()
second = slackio.get_dashboard_url()
assert call_count == 1, "get_parameter should be called only once"
assert first == expected_url
assert second == expected_url