mirror of
https://github.com/Sea-Haven-Industries/apm-wo-analysis.git
synced 2026-09-30 10:03:15 +00:00
333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""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
|