diff --git a/tests/test_interactions.py b/tests/test_interactions.py new file mode 100644 index 0000000..1b94097 --- /dev/null +++ b/tests/test_interactions.py @@ -0,0 +1,331 @@ +"""Tests for the Slack interactions Lambda endpoint. + +Covers: signature verification, drill_category modal, drill_site modal, +base64-encoded bodies, non-block_actions payloads, and missing-field no-ops. +""" + +from __future__ import annotations + +import base64 +import json +from urllib.parse import urlencode + + +def _make_event( + payload_dict: dict, + *, + timestamp: str = "1234567890", + signature: str = "v0=fakesig", + base64_encoded: bool = False, +) -> dict: + """Build a realistic API Gateway HTTP API event for Slack interactions.""" + raw_payload = urlencode({"payload": json.dumps(payload_dict)}) + body: str + if base64_encoded: + body = base64.b64encode(raw_payload.encode("utf-8")).decode("utf-8") + else: + body = raw_payload + return { + "headers": { + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + "content-type": "application/x-www-form-urlencoded", + }, + "body": body, + "isBase64Encoded": base64_encoded, + } + + +def _block_actions_payload( + action_id: str, + value: str, + trigger_id: str = "trigger123", +) -> dict: + return { + "type": "block_actions", + "trigger_id": trigger_id, + "actions": [ + { + "action_id": action_id, + "value": value, + } + ], + } + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_invalid_signature_returns_401_and_no_views_open(monkeypatch): + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: False) + + views_open_calls = [] + fake_client = type( + "C", (), {"views_open": lambda self, **kw: views_open_calls.append(kw)} + ) + monkeypatch.setattr(slackio, "web_client", lambda: fake_client()) + + event = _make_event( + _block_actions_payload( + "drill_category:Report / Docs Needed", "Report / Docs Needed" + ) + ) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 401 + assert "invalid" in resp["body"].lower() + assert views_open_calls == [] + + +def test_drill_category_opens_modal_with_matching_wos(monkeypatch): + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + monkeypatch.setattr( + slackio, "get_dashboard_url", lambda: "https://grafana.example.com" + ) + + details = [ + {"wo_number": "WO-001", "category": "Report / Docs Needed", "site": "ABQ5"}, + {"wo_number": "WO-002", "category": "3rd Escalation", "site": "ACY9"}, + {"wo_number": "WO-003", "category": "Report / Docs Needed", "site": "ABQ5"}, + ] + monkeypatch.setattr(slackio, "read_meta_json", lambda dt, name: details) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + event = _make_event( + _block_actions_payload( + "drill_category:Report / Docs Needed", "Report / Docs Needed" + ) + ) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert len(views_open_calls) == 1 + call = views_open_calls[0] + assert call["trigger_id"] == "trigger123" + view = call["view"] + # Title must reflect the count (2 matching WOs) + assert "2" in view["title"]["text"] + assert "Report / Docs Needed" in view["title"]["text"] + + +def test_drill_site_filters_on_site_field(monkeypatch): + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + monkeypatch.setattr( + slackio, "get_dashboard_url", lambda: "https://grafana.example.com" + ) + + details = [ + {"wo_number": "WO-001", "category": "Report / Docs Needed", "site": "ABQ5"}, + {"wo_number": "WO-002", "category": "3rd Escalation", "site": "ACY9"}, + {"wo_number": "WO-003", "category": "Awaiting Scheduling", "site": "ABQ5"}, + ] + monkeypatch.setattr(slackio, "read_meta_json", lambda dt, name: details) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + event = _make_event(_block_actions_payload("drill_site:ABQ5", "ABQ5")) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert len(views_open_calls) == 1 + view = views_open_calls[0]["view"] + # 2 WOs match site ABQ5 + assert "2" in view["title"]["text"] + assert "ABQ5" in view["title"]["text"] + + +def test_base64_encoded_body_is_decoded(monkeypatch): + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + monkeypatch.setattr( + slackio, "get_dashboard_url", lambda: "https://grafana.example.com" + ) + monkeypatch.setattr( + slackio, + "read_meta_json", + lambda dt, name: [ + {"wo_number": "WO-010", "category": "3rd Escalation", "site": "ACY9"} + ], + ) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + event = _make_event( + _block_actions_payload("drill_category:3rd Escalation", "3rd Escalation"), + base64_encoded=True, + ) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert len(views_open_calls) == 1 + + +def test_non_block_actions_type_returns_200_no_views_open(monkeypatch): + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + # "view_submission" is a valid Slack payload type but not block_actions + event = _make_event({"type": "view_submission"}) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert views_open_calls == [] + + +def test_unknown_action_kind_returns_200_no_views_open(monkeypatch): + """action_id prefix not in _FILTER_FIELD → no-op.""" + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + payload = { + "type": "block_actions", + "trigger_id": "tid", + "actions": [{"action_id": "open_dashboard", "value": "something"}], + } + event = _make_event(payload) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert views_open_calls == [] + + +def test_missing_value_returns_200_no_views_open(monkeypatch): + """action has a valid kind but missing value → no-op.""" + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + payload = { + "type": "block_actions", + "trigger_id": "tid", + "actions": [ + {"action_id": "drill_category:Report / Docs Needed"} + ], # no value key + } + event = _make_event(payload) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert views_open_calls == [] + + +def test_missing_trigger_id_returns_200_no_views_open(monkeypatch): + """Valid action but no trigger_id → no-op.""" + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + payload = { + "type": "block_actions", + # trigger_id absent + "actions": [ + { + "action_id": "drill_category:Report / Docs Needed", + "value": "Report / Docs Needed", + } + ], + } + event = _make_event(payload) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert views_open_calls == [] + + +def test_empty_details_json_shows_zero_wos_in_modal(monkeypatch): + """Details list is empty → views_open still called, modal title shows 0.""" + import interactions + import slackio + + monkeypatch.setattr(slackio, "verify_signature", lambda body, ts, sig: True) + monkeypatch.setattr( + slackio, "get_dashboard_url", lambda: "https://grafana.example.com" + ) + monkeypatch.setattr(slackio, "read_meta_json", lambda dt, name: []) + + views_open_calls = [] + fake_client = type( + "C", + (), + {"views_open": lambda self, **kw: views_open_calls.append(kw)}, + )() + monkeypatch.setattr(slackio, "web_client", lambda: fake_client) + + event = _make_event( + _block_actions_payload("drill_category:3rd Escalation", "3rd Escalation") + ) + resp = interactions.handler(event, None) + + assert resp["statusCode"] == 200 + assert len(views_open_calls) == 1 + assert "(0)" in views_open_calls[0]["view"]["title"]["text"]