mirror of
https://github.com/Sea-Haven-Industries/apm-wo-analysis.git
synced 2026-09-30 07:43:15 +00:00
Two push surfaces (no App Home) + interactive drill-down, per CLAUDE.md.
Block Kit (blockkit.py, pure/offline): build_daily_summary (header, vs-yesterday
deltas, escalation breakdown with 3rd highlighted, action/routine, top sites,
mismatch callout, category drill buttons + 📊 Open dashboard link, footer),
build_escalation_alert (one @here, returns None on zero-3rd — suppression), and
build_wo_modal (views.open payload, capped under Slack's 100-block limit).
Lambdas: slack_post/handler.py (classifier-invoked: read today/yesterday
summary.json, post daily summary, conditionally post the batched alert from
details.json) and slack_post/interactions.py (API Gateway: verify Slack
signature, filter details.json, views.open the WO modal within the 3s trigger_id
window). slackio.py centralizes Secrets Manager creds, the SSM dashboard URL,
signature verification, and analytics/ reads — keeping blockkit pure.
Classifier: emit analytics/dt=*/details.json (per-WO index for the modals) and
async-invoke slack-post after the snapshot write (best-effort; a Slack failure
never fails classification).
CDK: slack-post + interactions Lambdas (Docker-bundled slack_sdk), HTTP API on
apm-wo.seahaven.com (wildcard ACM cert + Route53 alias; signature-verified, so
the route is unauthenticated by design), SSM /apm-wo-analysis/grafana-base-url,
and scoped IAM (read analytics/, read the Slack secret + dashboard param;
classifier granted lambda:InvokeFunction on slack-post). Slack creds live in one
Secrets Manager secret apm-wo-analysis/slack-credentials {botToken, signingSecret,
channelId}; cdk.json gains cert/zone/domain context.
WO drill-downs link to Grafana only — no APM deep-links (per decision).
Deliverables for test time: slack/manifest.yaml (app manifest, interactivity
request_url = apm-wo.seahaven.com).
Tests: tests/test_blockkit.py (30 offline cases — deltas, zero-3rd None, <100
blocks under large inputs, modal truncation/overflow, dashboard URL) and Phase 4
assertions in test_pipeline_synth.py (both Lambdas, the API route/domain/alias,
and no broad/write IAM on the Slack roles). 49/49 tests pass; cdk synth green.
439 lines
16 KiB
Python
439 lines
16 KiB
Python
"""Offline unit tests for the Block Kit surface builders.
|
|
|
|
No AWS, no network, no slack_sdk. All fixtures are plain dicts.
|
|
|
|
Run with the repo venv:
|
|
|
|
./.venv/bin/python -m pytest tests/test_blockkit.py -q
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(
|
|
0, str(Path(__file__).resolve().parent.parent / "lambdas" / "slack_post")
|
|
)
|
|
|
|
import blockkit # noqa: E402
|
|
from blockkit import ( # noqa: E402
|
|
build_daily_summary,
|
|
build_escalation_alert,
|
|
build_wo_modal,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DASHBOARD_URL = "https://grafana.seahaven.internal/d/apm-wo"
|
|
|
|
CATEGORY_COUNTS_TYPICAL: dict[str, int] = {
|
|
"3rd Escalation": 4,
|
|
"2nd Escalation": 8,
|
|
"1st Escalation": 12,
|
|
"SIM Ticket": 2,
|
|
"Vendor No-Show": 3,
|
|
"Awaiting Scheduling": 25,
|
|
"Report / Docs Needed": 18,
|
|
"Awaiting Report / Invoice": 7,
|
|
"Awaiting Vendor / Parts": 5,
|
|
"Status Inquiry": 6,
|
|
"Other Escalation": 1,
|
|
"Schedule Confirmed": 90,
|
|
"Weekly WO Scheduled": 40,
|
|
"Completed / Pending Close": 22,
|
|
"Acknowledgement / No-op": 10,
|
|
"Cancelled": 5,
|
|
"On Hold": 3,
|
|
"Rescheduled": 4,
|
|
"Avetta Project Created": 2,
|
|
"Other": 15,
|
|
}
|
|
|
|
TODAY_SUMMARY: dict = {
|
|
"dt": "2026-05-28",
|
|
"classified_total": 282,
|
|
"blank_comment_rows": 68,
|
|
"category_counts": CATEGORY_COUNTS_TYPICAL,
|
|
"escalation_total": 27,
|
|
"third_escalation_count": 4,
|
|
"action_needed": 91,
|
|
"routine": 191,
|
|
"top_sites": [
|
|
{"site": "ABQ5", "count": 45},
|
|
{"site": "ACY9", "count": 38},
|
|
{"site": "BOS1", "count": 31},
|
|
{"site": "DFW7", "count": 28},
|
|
{"site": "LAX9", "count": 22},
|
|
],
|
|
"mismatches": [
|
|
{
|
|
"wo_number": "WO-001",
|
|
"category": "Completed / Pending Close",
|
|
"mismatch": "comment claims completion while on REPORT hold",
|
|
},
|
|
{
|
|
"wo_number": "WO-002",
|
|
"category": "Schedule Confirmed",
|
|
"mismatch": "comment confirms schedule while on SCHEDULING hold",
|
|
},
|
|
],
|
|
"generated_at": "2026-05-28T06:00:00Z",
|
|
}
|
|
|
|
YESTERDAY_SUMMARY: dict = {
|
|
"dt": "2026-05-27",
|
|
"classified_total": 270,
|
|
"blank_comment_rows": 80,
|
|
"category_counts": {},
|
|
"escalation_total": 30,
|
|
"third_escalation_count": 6,
|
|
"action_needed": 95,
|
|
"routine": 175,
|
|
"top_sites": [],
|
|
"mismatches": [],
|
|
"generated_at": "2026-05-27T06:00:00Z",
|
|
}
|
|
|
|
|
|
def _make_wo(i: int) -> dict:
|
|
return {
|
|
"wo_number": f"WO-{i:04d}",
|
|
"wo_description": f"Repair HVAC unit at dock {i}",
|
|
"site": "ABQ5",
|
|
"department": "SSP",
|
|
"category": "3rd Escalation",
|
|
"last_comment": f"3rd attempt to schedule vendor for dock {i} repair.",
|
|
"is_escalation": True,
|
|
"is_action": True,
|
|
"mismatch": None,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_daily_summary
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDailySummaryDeltas:
|
|
def test_with_yesterday_shows_deltas(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
# Collect all text from section/context blocks.
|
|
all_text = _extract_text(blocks)
|
|
# classified went 270 -> 282 (+12), escalation 30 -> 27 (-3).
|
|
assert "▲+12" in all_text, "expected classified delta ▲+12"
|
|
assert "▼-3" in all_text, "expected escalation delta ▼-3"
|
|
|
|
def test_without_yesterday_omits_deltas(self):
|
|
"""yesterday=None must not raise and must omit delta symbols."""
|
|
blocks = build_daily_summary(TODAY_SUMMARY, None, DASHBOARD_URL)
|
|
all_text = _extract_text(blocks)
|
|
assert "▲" not in all_text
|
|
assert "▼" not in all_text
|
|
assert "No prior day" in all_text
|
|
|
|
def test_without_yesterday_returns_blocks(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, None, DASHBOARD_URL)
|
|
assert isinstance(blocks, list)
|
|
assert len(blocks) > 0
|
|
|
|
def test_zero_delta_shows_tilde(self):
|
|
yesterday_same = {**YESTERDAY_SUMMARY, "escalation_total": 27}
|
|
blocks = build_daily_summary(TODAY_SUMMARY, yesterday_same, DASHBOARD_URL)
|
|
all_text = _extract_text(blocks)
|
|
assert "~0" in all_text
|
|
|
|
|
|
class TestDailySummaryStructure:
|
|
def test_has_header_block(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
assert any(b.get("type") == "header" for b in blocks)
|
|
|
|
def test_header_contains_date(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
header = next(b for b in blocks if b.get("type") == "header")
|
|
assert "2026" in header["text"]["text"] or "May" in header["text"]["text"]
|
|
|
|
def test_mismatch_callout_present_when_mismatches_nonzero(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
all_text = _extract_text(blocks)
|
|
assert "mismatch" in all_text.lower()
|
|
|
|
def test_mismatch_callout_absent_when_no_mismatches(self):
|
|
today_no_mm = {**TODAY_SUMMARY, "mismatches": []}
|
|
blocks = build_daily_summary(today_no_mm, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
all_text = _extract_text(blocks)
|
|
assert "mismatch" not in all_text.lower()
|
|
|
|
def test_dashboard_link_button_url(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
url = _find_link_button_url(blocks)
|
|
assert url == DASHBOARD_URL, f"expected {DASHBOARD_URL!r}, got {url!r}"
|
|
|
|
def test_dashboard_button_has_no_action_id(self):
|
|
"""The Open-dashboard button must be a url button, not an action button."""
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
for block in blocks:
|
|
if block.get("type") != "actions":
|
|
continue
|
|
for elem in block.get("elements", []):
|
|
if elem.get("url") == DASHBOARD_URL:
|
|
assert "action_id" not in elem, (
|
|
"dashboard link button must not carry an action_id"
|
|
)
|
|
|
|
def test_drill_buttons_have_action_id(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
drill_found = False
|
|
for block in blocks:
|
|
if block.get("type") != "actions":
|
|
continue
|
|
for elem in block.get("elements", []):
|
|
if elem.get("action_id") == "drill_category":
|
|
drill_found = True
|
|
assert "value" in elem
|
|
assert drill_found, "expected at least one drill_category action button"
|
|
|
|
def test_3rd_escalation_highlighted(self):
|
|
blocks = build_daily_summary(TODAY_SUMMARY, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
all_text = _extract_text(blocks)
|
|
# 3rd Escalation should appear in bold mrkdwn.
|
|
assert "*3rd Escalation" in all_text or "3rd Escalation*" in all_text
|
|
|
|
|
|
class TestDailySummaryBlockLimit:
|
|
def _large_summary(self) -> dict:
|
|
"""Summary with 25+ categories, 30 sites, and 200 mismatches."""
|
|
big_cats: dict[str, int] = {f"Category {i}": i + 1 for i in range(30)}
|
|
big_cats.update(CATEGORY_COUNTS_TYPICAL)
|
|
big_sites = [{"site": f"S{i:03d}", "count": 30 - i} for i in range(30)]
|
|
big_mismatches = [
|
|
{"wo_number": f"WO-{i:04d}", "category": "X", "mismatch": "test mm"}
|
|
for i in range(200)
|
|
]
|
|
return {
|
|
**TODAY_SUMMARY,
|
|
"category_counts": big_cats,
|
|
"top_sites": big_sites,
|
|
"mismatches": big_mismatches,
|
|
}
|
|
|
|
def test_stays_under_100_blocks_large_input(self):
|
|
big = self._large_summary()
|
|
blocks = build_daily_summary(big, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
assert len(blocks) < 100, f"daily summary exceeded 100 blocks: {len(blocks)}"
|
|
|
|
def test_reports_block_count(self, capsys):
|
|
big = self._large_summary()
|
|
blocks = build_daily_summary(big, YESTERDAY_SUMMARY, DASHBOARD_URL)
|
|
with capsys.disabled():
|
|
print(f"\n[test] daily summary (large input): {len(blocks)} blocks")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_escalation_alert
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEscalationAlert:
|
|
def test_returns_none_on_empty_list(self):
|
|
assert build_escalation_alert([]) is None
|
|
|
|
def test_returns_blocks_on_non_empty(self):
|
|
escalations = [_make_wo(i) for i in range(3)]
|
|
result = build_escalation_alert(escalations)
|
|
assert result is not None
|
|
assert isinstance(result, list)
|
|
assert len(result) > 0
|
|
|
|
def test_exactly_one_here_mention(self):
|
|
escalations = [_make_wo(i) for i in range(5)]
|
|
blocks = build_escalation_alert(escalations)
|
|
assert blocks is not None
|
|
all_text = _extract_text(blocks)
|
|
here_count = all_text.count("<!here>")
|
|
assert here_count == 1, f"expected exactly 1 <!here>, found {here_count}"
|
|
|
|
def test_here_in_section_not_header(self):
|
|
"""@here must be in a section block, not the header."""
|
|
escalations = [_make_wo(i) for i in range(2)]
|
|
blocks = build_escalation_alert(escalations)
|
|
assert blocks is not None
|
|
for block in blocks:
|
|
if block.get("type") == "header":
|
|
header_text = block["text"]["text"]
|
|
assert "<!here>" not in header_text
|
|
|
|
def test_no_here_on_empty(self):
|
|
result = build_escalation_alert([])
|
|
assert result is None
|
|
|
|
def test_overflow_note_when_many_wos(self):
|
|
"""When list exceeds ALERT_MAX_WOS, a '+M more' context block is added."""
|
|
many = [_make_wo(i) for i in range(blockkit.ALERT_MAX_WOS + 10)]
|
|
blocks = build_escalation_alert(many)
|
|
assert blocks is not None
|
|
all_text = _extract_text(blocks)
|
|
assert "more" in all_text.lower()
|
|
|
|
def test_no_overflow_note_when_within_limit(self):
|
|
few = [_make_wo(i) for i in range(blockkit.ALERT_MAX_WOS - 1)]
|
|
blocks = build_escalation_alert(few)
|
|
assert blocks is not None
|
|
all_text = _extract_text(blocks)
|
|
# Should not have a "+N more" line.
|
|
assert " more —" not in all_text
|
|
|
|
def test_single_wo_grammatically_correct(self):
|
|
"""'WO' (singular) when count is 1."""
|
|
blocks = build_escalation_alert([_make_wo(1)])
|
|
assert blocks is not None
|
|
header = next(b for b in blocks if b.get("type") == "header")
|
|
assert "WOs" not in header["text"]["text"], "expected singular 'WO' not 'WOs'"
|
|
|
|
def test_stays_under_100_blocks(self):
|
|
huge = [_make_wo(i) for i in range(500)]
|
|
blocks = build_escalation_alert(huge)
|
|
assert blocks is not None
|
|
assert len(blocks) < 100, f"alert exceeded 100 blocks: {len(blocks)}"
|
|
|
|
def test_wo_number_appears_in_blocks(self):
|
|
escalations = [_make_wo(42)]
|
|
blocks = build_escalation_alert(escalations)
|
|
assert blocks is not None
|
|
all_text = _extract_text(blocks)
|
|
assert "WO-0042" in all_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_wo_modal
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWoModal:
|
|
def test_returns_dict_with_modal_type(self):
|
|
modal = build_wo_modal("Test Modal", [_make_wo(1)], DASHBOARD_URL)
|
|
assert isinstance(modal, dict)
|
|
assert modal.get("type") == "modal"
|
|
|
|
def test_modal_has_close_button(self):
|
|
modal = build_wo_modal("Test", [_make_wo(1)], DASHBOARD_URL)
|
|
assert "close" in modal
|
|
assert modal["close"]["type"] == "plain_text"
|
|
|
|
def test_title_truncated_to_24_chars(self):
|
|
long_title = "A" * 50
|
|
modal = build_wo_modal(long_title, [_make_wo(1)], DASHBOARD_URL)
|
|
assert len(modal["title"]["text"]) <= 24
|
|
|
|
def test_500_wos_truncated_to_under_100_blocks(self, capsys):
|
|
wos = [_make_wo(i) for i in range(500)]
|
|
modal = build_wo_modal("3rd Escalations", wos, DASHBOARD_URL)
|
|
block_count = len(modal["blocks"])
|
|
with capsys.disabled():
|
|
print(f"\n[test] 500-WO modal: {block_count} blocks")
|
|
assert block_count <= 100, (
|
|
f"modal with 500 WOs has {block_count} blocks, exceeds 100"
|
|
)
|
|
|
|
def test_500_wos_has_overflow_note(self):
|
|
wos = [_make_wo(i) for i in range(500)]
|
|
modal = build_wo_modal("3rd Escalations", wos, DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
assert "more" in all_text.lower(), "expected '+M more' overflow note"
|
|
|
|
def test_overflow_note_contains_dashboard_url(self):
|
|
wos = [_make_wo(i) for i in range(500)]
|
|
modal = build_wo_modal("3rd Escalations", wos, DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
assert DASHBOARD_URL in all_text, "overflow note must link to the dashboard URL"
|
|
|
|
def test_overflow_count_correct(self):
|
|
"""The overflow note's M value must equal len(wos) - rendered_count."""
|
|
n = 500
|
|
wos = [_make_wo(i) for i in range(n)]
|
|
modal = build_wo_modal("3rd Escalations", wos, DASHBOARD_URL)
|
|
blocks = modal["blocks"]
|
|
# Last block should be context with the overflow note.
|
|
last_text = _extract_text([blocks[-1]])
|
|
# The overflow note pattern is "+M more".
|
|
import re
|
|
|
|
match = re.search(r"\+(\d+) more", last_text)
|
|
assert match, f"no '+M more' pattern in last block: {last_text!r}"
|
|
reported_overflow = int(match.group(1))
|
|
# Block count: total blocks = visible WO blocks + 1 overflow block.
|
|
rendered_wo_blocks = len(blocks) - 1
|
|
assert reported_overflow == n - rendered_wo_blocks, (
|
|
f"overflow note says +{reported_overflow} but "
|
|
f"{n} - {rendered_wo_blocks} = {n - rendered_wo_blocks}"
|
|
)
|
|
|
|
def test_empty_wo_list_returns_placeholder(self):
|
|
modal = build_wo_modal("Empty", [], DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
assert "no work orders" in all_text.lower()
|
|
|
|
def test_wo_number_appears_in_blocks(self):
|
|
modal = build_wo_modal("Test", [_make_wo(7)], DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
assert "WO-0007" in all_text
|
|
|
|
def test_snippet_truncated_to_150_chars(self):
|
|
wo = _make_wo(1)
|
|
wo["last_comment"] = "X" * 300
|
|
modal = build_wo_modal("Test", [wo], DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
# The snippet in the text should not be 300 X's.
|
|
assert "X" * 200 not in all_text
|
|
|
|
def test_no_apm_deep_links(self):
|
|
"""Modal must not contain any APM application deep-links."""
|
|
wos = [_make_wo(i) for i in range(10)]
|
|
modal = build_wo_modal("3rd Escalations", wos, DASHBOARD_URL)
|
|
all_text = _extract_text(modal["blocks"])
|
|
# APM URLs would start with typical patterns — confirm none present.
|
|
assert "apm://" not in all_text
|
|
assert "app.apm" not in all_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _extract_text(blocks: list[dict]) -> str:
|
|
"""Collect all text strings from blocks for assertion convenience."""
|
|
parts: list[str] = []
|
|
for block in blocks:
|
|
btype = block.get("type")
|
|
if btype == "header":
|
|
parts.append(block.get("text", {}).get("text", ""))
|
|
elif btype == "section":
|
|
text_obj = block.get("text")
|
|
if text_obj:
|
|
parts.append(text_obj.get("text", ""))
|
|
for field in block.get("fields", []):
|
|
parts.append(field.get("text", ""))
|
|
elif btype == "context":
|
|
for elem in block.get("elements", []):
|
|
parts.append(elem.get("text", ""))
|
|
elif btype == "actions":
|
|
for elem in block.get("elements", []):
|
|
txt = elem.get("text", {})
|
|
if isinstance(txt, dict):
|
|
parts.append(txt.get("text", ""))
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _find_link_button_url(blocks: list[dict]) -> str | None:
|
|
"""Return the URL from the first link button found in any actions block."""
|
|
for block in blocks:
|
|
if block.get("type") != "actions":
|
|
continue
|
|
for elem in block.get("elements", []):
|
|
if "url" in elem:
|
|
return elem["url"]
|
|
return None
|