mirror of
https://github.com/Sea-Haven-Industries/apm-wo-analysis.git
synced 2026-09-30 06:33:14 +00:00
Merge pull request #16 from Sea-Haven-Industries/test/suite-hardening
Harden the test suite and wire it into CI
This commit is contained in:
commit
6535b92e85
12 changed files with 2004 additions and 8 deletions
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
|
|
@ -12,5 +12,5 @@ jobs:
|
||||||
run-sam-validate: false
|
run-sam-validate: false
|
||||||
run-cdk-synth: true
|
run-cdk-synth: true
|
||||||
cdk-dir: cdk
|
cdk-dir: cdk
|
||||||
run-tests: false
|
run-tests: true
|
||||||
enable-qemu: true
|
enable-qemu: true
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -162,18 +162,24 @@ Any `.xlsx`/`.csv` landing under `raw/` invokes the classifier.
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
- pyenv Python 3.12. `ruff check` + `ruff format --check` before pushing (hook-enforced).
|
- pyenv Python 3.12. `ruff check` + `ruff format --check` before pushing (hook-enforced).
|
||||||
- Tests: `python -m pytest tests/ -q` (60 tests — classifier smoke test against a real
|
- Tests: `python -m pytest tests/ -q` (~158 tests — classifier rule ladder + handler
|
||||||
export + offline `cdk.assertions` synth checks + Block Kit builders). No AWS needed.
|
transforms + Haiku fallback, Block Kit builders, the signature-verified Slack
|
||||||
- Smoke-test the classifier against a **real export** before declaring any
|
interactions endpoint, slack-post orchestration, and offline `cdk.assertions` synth
|
||||||
classification change done: `~/Downloads/_documents/Sheet1-1.xlsx`.
|
checks). No AWS needed — external boundaries are monkeypatched. **Tests run in CI**
|
||||||
- `cdk synth` must pass in CI before merge (**Docker required** — Lambda deps are
|
(`ci.yaml` sets `run-tests: true`); coverage is reported via `pytest-cov`
|
||||||
bundled for ARM64).
|
(`tests/requirements.txt`, ~94%, non-gating).
|
||||||
|
- The classification quality gate (deterministic "Other" share) runs in CI against a
|
||||||
|
committed synthetic fixture `tests/fixtures/sample_export.csv`. Additionally,
|
||||||
|
smoke-test against the **real export** before declaring any classification change
|
||||||
|
done: `~/Downloads/_documents/Sheet1-1.xlsx` (skips automatically when absent).
|
||||||
|
- `cdk synth` must pass in CI before merge (**Docker + QEMU** — Lambda deps are
|
||||||
|
bundled for ARM64; `ci.yaml` sets `enable-qemu: true`).
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
CI/CD via the org reusable workflows (no manual prod deploys in steady state):
|
CI/CD via the org reusable workflows (no manual prod deploys in steady state):
|
||||||
|
|
||||||
- **CI** (`.github/workflows/ci.yaml`) → `ci-python-sam.yaml@main`: ruff + `cdk synth`. Runs on PRs into `main`.
|
- **CI** (`.github/workflows/ci.yaml`) → `ci-python-sam.yaml@main`: ruff + `pytest` + `cdk synth` (QEMU-enabled). Runs on PRs into `main`.
|
||||||
- **Deploy** (`.github/workflows/deploy.yaml`) → `cd-cdk.yaml@main`: OIDC assume-role, `cdk deploy --all`, single-flight concurrency. Runs on push to `main`.
|
- **Deploy** (`.github/workflows/deploy.yaml`) → `cd-cdk.yaml@main`: OIDC assume-role, `cdk deploy --all`, single-flight concurrency. Runs on push to `main`.
|
||||||
|
|
||||||
Stack name/region/account: `apm-wo-analysis-{pipeline,grafana}` / us-east-1 / 328440206208.
|
Stack name/region/account: `apm-wo-analysis-{pipeline,grafana}` / us-east-1 / 328440206208.
|
||||||
|
|
|
||||||
4
pyproject.toml
Normal file
4
pyproject.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["lambdas/classifier", "lambdas/slack_post", "cdk"]
|
||||||
|
addopts = "--cov=lambdas --cov-report=term-missing"
|
||||||
20
tests/conftest.py
Normal file
20
tests/conftest.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Central test configuration.
|
||||||
|
|
||||||
|
Sets dummy environment variables at import time so modules that read
|
||||||
|
``os.environ`` at module load (slackio, interactions, handler) can be imported
|
||||||
|
without real AWS credentials. Real values are monkeypatched per-test where
|
||||||
|
needed.
|
||||||
|
|
||||||
|
A default AWS region is set too: the handler/slackio modules construct
|
||||||
|
``boto3.client(...)`` at import time, which raises ``NoRegionError`` on a CI
|
||||||
|
runner with no AWS config. Client construction is offline; every real AWS call
|
||||||
|
is monkeypatched in the tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SLACK_SECRET_NAME", "test-slack-secret")
|
||||||
|
os.environ.setdefault("DASHBOARD_URL_PARAM", "/test/dashboard-url")
|
||||||
|
os.environ.setdefault("ANALYTICS_BUCKET", "test-bucket")
|
||||||
|
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
|
||||||
|
os.environ.setdefault("AWS_REGION", "us-east-1")
|
||||||
31
tests/fixtures/sample_export.csv
vendored
Normal file
31
tests/fixtures/sample_export.csv
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
WO Number,WO Description,Equipment Code,Organization,Due Date,Department,WO Status,Hold Reason,Last Comment,Last Comment By,Last Comment Date,Contractor,Contractor Description
|
||||||
|
WO-1001,Repair HVAC unit at dock 3,HVAC-001,ABQ5,2026-05-15,SSP,H,REPORT,<html><div>3rd attempt process for schedule confirmation. Vendor please confirm schedule start date and proceed.</div></html>,J.Smith,2026-05-10,Acme HVAC,Acme HVAC Services
|
||||||
|
WO-1002,Replace lighting ballast,LIGHT-042,ACY9,2026-05-16,RME,H,SCHEDULING,<html><div>2nd attempt process for schedule confirmation. Please provide a confirmed date for this work order.</div></html>,T.Jones,2026-05-11,Bright Electric,Bright Electric LLC
|
||||||
|
WO-1003,Fix water leak in restroom,PLUMB-007,BOS1,2026-05-17,SSP,IP,,<html><div>1st escalation sent to vendor. Awaiting response from contractor regarding scheduling.</div></html>,M.Davis,2026-05-12,Metro Plumbing,Metro Plumbing Inc
|
||||||
|
WO-1004,Inspect fire suppression system,FIRE-015,ABQ5,2026-05-18,SSP,R,,<html><div>SIM ticket TT-12345678 opened for this work order. Tracking in Amazon SIM system.</div></html>,K.Wilson,2026-05-13,FireSafe Co,FireSafe Company
|
||||||
|
WO-1005,Service generator unit,GEN-003,ACY9,2026-05-19,RME,R,,<html><div>Site tech reported vendor was a no show for Tuesday. Rescheduling required.</div></html>,P.Brown,2026-05-14,Power Gen LLC,Power Generation LLC
|
||||||
|
WO-1006,Calibrate dock door sensors,DOCK-022,BOS1,2026-05-20,SSP,IP,,<html><div>WO schedule confirmed with vendor for 5/22. All parties notified.</div></html>,L.Garcia,2026-05-15,Dock Systems,Dock Systems Inc
|
||||||
|
WO-1007,Weekly PM on conveyor belt,CONV-011,ABQ5,2026-05-21,RME,IP,,<html><div>Weekly WO scheduled. Service reports required EOD Friday per standard cadence.</div></html>,R.Martinez,2026-05-16,Belt Tech,Belt Tech Services
|
||||||
|
WO-1008,Replace worn floor mats,FLOOR-009,ACY9,2026-05-22,BBM,H,REPORT,<html></html>,A.Thompson,2026-05-17,Clean Facility,Clean Facility Services
|
||||||
|
WO-1009,Inspect sprinkler heads,FIRE-031,BOS1,2026-05-23,SSP,H,SCHEDULING,<html></html>,B.Anderson,2026-05-18,FireSafe Co,FireSafe Company
|
||||||
|
WO-1010,Order replacement motor,MOTOR-005,ABQ5,2026-05-24,RME,H,VENDOR,<html></html>,C.Jackson,2026-05-19,Motor Supply,Motor Supply Co
|
||||||
|
WO-1011,Cancel duplicate work order,WO-DUP-001,ACY9,2026-05-25,SSP,RCAN,,<html><div>WO cancelled, created in error. Duplicate of WO-1010.</div></html>,D.White,2026-05-20,N/A,N/A
|
||||||
|
WO-1012,Perform task and close,TASK-088,BOS1,2026-05-26,RME,H,REPORT,<html><div>Vendor arrived and performed task. All work completed satisfactorily.</div></html>,E.Harris,2026-05-21,General Contractors,General Contractors LLC
|
||||||
|
WO-1013,PM on roof HVAC,HVAC-099,ABQ5,2026-05-27,SSP,R,,<html><div>WO schedule confirmed with vendor for 5/29 start time 0800.</div></html>,F.Clark,2026-05-22,Acme HVAC,Acme HVAC Services
|
||||||
|
WO-1014,Service dock bumpers weekly,DOCK-044,ACY9,2026-05-28,SSP,IP,,<html><div>Weekly WO scheduled. Service reports are required weekly per maintenance plan.</div></html>,G.Lewis,2026-05-23,Dock Systems,Dock Systems Inc
|
||||||
|
WO-1015,Replace battery backup,UPS-002,BOS1,2026-05-29,RME,H,SCHEDULING,<html><div>2nd attempt process for schedule confirmation. Vendor has not responded to prior outreach.</div></html>,H.Robinson,2026-05-24,Power Gen LLC,Power Generation LLC
|
||||||
|
WO-1016,Inspect emergency exits,EXIT-007,ABQ5,2026-05-30,SSP,R,,<html><div>3rd attempt process for schedule confirmation. Escalating to vendor management for response.</div></html>,I.Walker,2026-05-25,Safety First,Safety First Inc
|
||||||
|
WO-1017,Fix broken dock seal,DOCK-055,ACY9,2026-05-31,SSP,IP,,<html><div>Vendor confirmed schedule for 6/2 arrival.</div></html>,J.Hall,2026-05-26,Dock Systems,Dock Systems Inc
|
||||||
|
WO-1018,Service fire extinguishers,FIRE-022,BOS1,2026-06-01,SSP,RCAN,,<html><div>WO cancelled. Work was already completed under WO-1004.</div></html>,K.Young,2026-05-27,FireSafe Co,FireSafe Company
|
||||||
|
WO-1019,PM on cooling tower,COOL-001,ABQ5,2026-06-02,RME,H,REPORT,<html></html>,L.Allen,2026-05-28,Cooling Tech,Cooling Tech LLC
|
||||||
|
WO-1020,Inspect electrical panel,ELEC-018,ACY9,2026-06-03,RME,H,VENDOR,<html></html>,M.King,2026-05-29,Volt Electric,Volt Electric Services
|
||||||
|
WO-1021,Replace worn casters on cart,CART-003,BOS1,2026-06-04,BBM,IP,,<html><div>Procurement to provide PO for parts order before work can begin.</div></html>,N.Wright,2026-05-30,General Contractors,General Contractors LLC
|
||||||
|
WO-1022,Patch roof leak,ROOF-006,ABQ5,2026-06-05,SSP,IP,,<html><div>Copy.</div></html>,O.Scott,2026-05-31,Roof Masters,Roof Masters Inc
|
||||||
|
WO-1023,Service dock bumpers,DOCK-066,ACY9,2026-06-06,SSP,R,,<html><div>Schedule confirmed with vendor for next Tuesday 0900.</div></html>,P.Green,2026-06-01,Dock Systems,Dock Systems Inc
|
||||||
|
WO-1024,Repair bathroom faucet,PLUMB-012,BOS1,2026-06-07,SSP,IP,,<html><div>WO schedule confirmed with vendor. Technician on site 6/9.</div></html>,Q.Baker,2026-06-02,Metro Plumbing,Metro Plumbing Inc
|
||||||
|
WO-1025,Recalibrate HVAC thermostat,HVAC-033,ABQ5,2026-06-08,RME,H,SCHEDULING,<html></html>,R.Adams,2026-06-03,Acme HVAC,Acme HVAC Services
|
||||||
|
WO-1026,Fix overhead door,DOOR-019,ACY9,2026-06-09,SSP,IP,,<html><div>Uplift request submitted pending management approval.</div></html>,S.Nelson,2026-06-04,Door Pro,Door Pro LLC
|
||||||
|
WO-1027,Replace signage,SIGN-004,BOS1,2026-06-10,BBM,IP,,,,2026-06-05,Sign Works,Sign Works Co
|
||||||
|
WO-1028,Inspect conveyor belt,CONV-022,ABQ5,2026-06-11,RME,IP,,,T.Carter,2026-06-06,Belt Tech,Belt Tech Services
|
||||||
|
WO-1029,Lubricate dock equipment,DOCK-077,ACY9,2026-06-12,SSP,R,,,U.Mitchell,2026-06-07,Dock Systems,Dock Systems Inc
|
||||||
|
WO-1030,Weekly PM on generator,GEN-008,BOS1,2026-06-13,RME,IP,,<html><div>Weekly WO scheduled. Service reports required EOD Friday.</div></html>,V.Perez,2026-06-08,Power Gen LLC,Power Generation LLC
|
||||||
|
Can't render this file because it has a wrong number of fields in line 12.
|
8
tests/requirements.txt
Normal file
8
tests/requirements.txt
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Test-only deps. boto3/pandas/awswrangler are NOT in the Lambda packages
|
||||||
|
# (they come from the SDK-for-pandas layer + runtime), but the handler/slackio
|
||||||
|
# modules import them at load time, so they're needed to import-and-test here.
|
||||||
|
pytest
|
||||||
|
pytest-cov
|
||||||
|
boto3
|
||||||
|
pandas
|
||||||
|
awswrangler
|
||||||
523
tests/test_classifier_handler.py
Normal file
523
tests/test_classifier_handler.py
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
"""Tests for the classifier handler: pure transforms and the handler() entrypoint.
|
||||||
|
|
||||||
|
Uses the importlib trick to avoid an ambiguous bare ``import handler`` (both
|
||||||
|
lambdas/classifier/handler.py and lambdas/slack_post/handler.py are on
|
||||||
|
pythonpath). ``awswrangler`` is stubbed at the sys.modules level before the
|
||||||
|
module is loaded, so no real AWS/network calls are made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Load the classifier handler under a unique module name.
|
||||||
|
# awswrangler must be stubbed before exec_module() runs the top-level imports.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Build a minimal awswrangler stub so handler.py's top-level `import awswrangler as wr`
|
||||||
|
# succeeds without the real package installed.
|
||||||
|
_wr_stub = types.ModuleType("awswrangler")
|
||||||
|
_wr_stub.s3 = types.ModuleType("awswrangler.s3")
|
||||||
|
_wr_stub.s3.to_parquet = MagicMock()
|
||||||
|
sys.modules.setdefault("awswrangler", _wr_stub)
|
||||||
|
sys.modules.setdefault("awswrangler.s3", _wr_stub.s3)
|
||||||
|
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
|
||||||
|
_HANDLER_PATH = (
|
||||||
|
Path(__file__).resolve().parents[1] / "lambdas" / "classifier" / "handler.py"
|
||||||
|
)
|
||||||
|
_spec = importlib.util.spec_from_file_location("classifier_handler", _HANDLER_PATH)
|
||||||
|
handler = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(handler)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HEADER = [
|
||||||
|
"WO Number",
|
||||||
|
"WO Description",
|
||||||
|
"Equipment Code",
|
||||||
|
"Organization",
|
||||||
|
"Due Date",
|
||||||
|
"Department",
|
||||||
|
"WO Status",
|
||||||
|
"Hold Reason",
|
||||||
|
"Last Comment",
|
||||||
|
"Last Comment By",
|
||||||
|
"Last Comment Date",
|
||||||
|
"Contractor",
|
||||||
|
"Contractor Description",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_row(
|
||||||
|
wo_number="WO-001",
|
||||||
|
wo_description="Fix HVAC",
|
||||||
|
equipment_code="HVAC-01",
|
||||||
|
site="ABQ5",
|
||||||
|
due_date="2026-05-30",
|
||||||
|
department="SSP",
|
||||||
|
wo_status="IP",
|
||||||
|
hold_reason="",
|
||||||
|
last_comment="<html>WO schedule confirmed with vendor.</html>",
|
||||||
|
last_comment_by="tech@example.com",
|
||||||
|
last_comment_date="2026-05-28",
|
||||||
|
contractor="ABC HVAC",
|
||||||
|
contractor_description="HVAC Services",
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
wo_number,
|
||||||
|
wo_description,
|
||||||
|
equipment_code,
|
||||||
|
site,
|
||||||
|
due_date,
|
||||||
|
department,
|
||||||
|
wo_status,
|
||||||
|
hold_reason,
|
||||||
|
last_comment,
|
||||||
|
last_comment_by,
|
||||||
|
last_comment_date,
|
||||||
|
contractor,
|
||||||
|
contractor_description,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_csv(rows: list[list], header: list[str] = _HEADER) -> str:
|
||||||
|
"""Write header + rows to a temp CSV file and return the path."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, newline=""
|
||||||
|
) as fh:
|
||||||
|
writer = csv.writer(fh)
|
||||||
|
writer.writerow(header)
|
||||||
|
writer.writerows(rows)
|
||||||
|
return fh.name
|
||||||
|
|
||||||
|
|
||||||
|
def _write_xlsx(rows: list[list], header: list[str] = _HEADER) -> str:
|
||||||
|
"""Write header + rows to a temp xlsx file and return the path."""
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.append(header)
|
||||||
|
for row in rows:
|
||||||
|
ws.append(row)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as fh:
|
||||||
|
path = fh.name
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _resolve_columns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveColumns:
|
||||||
|
def test_normal_13_col_header(self):
|
||||||
|
result = handler._resolve_columns(_HEADER)
|
||||||
|
assert result["wo_number"] == 0
|
||||||
|
assert result["site"] == 3 # "Organization" column
|
||||||
|
assert result["wo_status"] == 6
|
||||||
|
assert result["hold_reason"] == 7
|
||||||
|
assert result["last_comment"] == 8
|
||||||
|
assert result["last_comment_by"] == 9
|
||||||
|
assert result["last_comment_date"] == 10
|
||||||
|
|
||||||
|
def test_header_drift_extra_spaces_and_case(self):
|
||||||
|
drifted = [
|
||||||
|
" WO NUMBER ",
|
||||||
|
"WO DESCRIPTION",
|
||||||
|
"Equipment Code",
|
||||||
|
"Organization",
|
||||||
|
"Due Date",
|
||||||
|
"Department",
|
||||||
|
"WO Status ",
|
||||||
|
"Hold Reason",
|
||||||
|
"Last Comment",
|
||||||
|
"Last Comment By",
|
||||||
|
"Last Comment Date",
|
||||||
|
"Contractor",
|
||||||
|
"Contractor Description",
|
||||||
|
]
|
||||||
|
result = handler._resolve_columns(drifted)
|
||||||
|
assert result["wo_number"] == 0
|
||||||
|
assert result["wo_status"] == 6
|
||||||
|
assert result["last_comment"] == 8
|
||||||
|
|
||||||
|
def test_prefix_collision_last_comment_resolves_to_exact_column(self):
|
||||||
|
"""'last comment' must resolve to col 8 (Last Comment), not col 9 or 10."""
|
||||||
|
result = handler._resolve_columns(_HEADER)
|
||||||
|
col_idx = result["last_comment"]
|
||||||
|
assert col_idx == 8
|
||||||
|
assert _HEADER[col_idx] == "Last Comment"
|
||||||
|
|
||||||
|
# last_comment_by and last_comment_date must not steal last_comment's slot
|
||||||
|
assert result["last_comment_by"] == 9
|
||||||
|
assert result["last_comment_date"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _read_rows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadRows:
|
||||||
|
def test_csv_header_and_rows(self):
|
||||||
|
data = [_make_row(wo_number="WO-001"), _make_row(wo_number="WO-002")]
|
||||||
|
path = _write_csv(data)
|
||||||
|
header, rows = handler._read_rows(path, "raw/export.csv")
|
||||||
|
assert header[0] == "WO Number"
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0][0] == "WO-001"
|
||||||
|
assert rows[1][0] == "WO-002"
|
||||||
|
|
||||||
|
def test_xlsx_header_and_rows(self):
|
||||||
|
data = [_make_row(wo_number="WO-003"), _make_row(wo_number="WO-004")]
|
||||||
|
path = _write_xlsx(data)
|
||||||
|
header, rows = handler._read_rows(path, "raw/export.xlsx")
|
||||||
|
assert header[0] == "WO Number"
|
||||||
|
assert len(rows) == 2
|
||||||
|
assert rows[0][0] == "WO-003"
|
||||||
|
assert rows[1][0] == "WO-004"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSnapshot:
|
||||||
|
def test_blank_comment_rows_excluded(self):
|
||||||
|
rows = [
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-010", last_comment="<html>Schedule confirmed.</html>"
|
||||||
|
),
|
||||||
|
_make_row(wo_number="WO-011", last_comment=""), # blank — excluded
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-012", last_comment="<html></html>"
|
||||||
|
), # strips to "" — excluded
|
||||||
|
]
|
||||||
|
df, blank = handler._build_snapshot(_HEADER, rows)
|
||||||
|
assert blank == 2
|
||||||
|
assert len(df) == 1
|
||||||
|
assert df.iloc[0]["wo_number"] == "WO-010"
|
||||||
|
|
||||||
|
def test_missing_last_comment_column_raises(self):
|
||||||
|
# Remove all "last comment" variants so last_comment cannot resolve via
|
||||||
|
# substring match either — only columns with no "last comment" remain.
|
||||||
|
bad_header = [
|
||||||
|
"WO Number",
|
||||||
|
"WO Description",
|
||||||
|
"Equipment Code",
|
||||||
|
"Organization",
|
||||||
|
"Due Date",
|
||||||
|
"Department",
|
||||||
|
"WO Status",
|
||||||
|
"Hold Reason",
|
||||||
|
"Contractor",
|
||||||
|
"Contractor Description",
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
"WO-001",
|
||||||
|
"Fix HVAC",
|
||||||
|
"HVAC",
|
||||||
|
"ABQ5",
|
||||||
|
"2026-05-30",
|
||||||
|
"SSP",
|
||||||
|
"IP",
|
||||||
|
"",
|
||||||
|
"ABC",
|
||||||
|
"HVAC",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="required columns"):
|
||||||
|
handler._build_snapshot(bad_header, rows)
|
||||||
|
|
||||||
|
def test_missing_wo_status_column_raises(self):
|
||||||
|
bad_header = [
|
||||||
|
"WO Number",
|
||||||
|
"WO Description",
|
||||||
|
"Equipment Code",
|
||||||
|
"Organization",
|
||||||
|
"Due Date",
|
||||||
|
"Department",
|
||||||
|
# "WO Status" missing
|
||||||
|
"Hold Reason",
|
||||||
|
"Last Comment",
|
||||||
|
"Last Comment By",
|
||||||
|
"Last Comment Date",
|
||||||
|
"Contractor",
|
||||||
|
"Contractor Description",
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
[
|
||||||
|
"WO-001",
|
||||||
|
"Fix HVAC",
|
||||||
|
"HVAC",
|
||||||
|
"ABQ5",
|
||||||
|
"2026-05-30",
|
||||||
|
"SSP",
|
||||||
|
"",
|
||||||
|
"<html>Schedule confirmed.</html>",
|
||||||
|
"tech@example.com",
|
||||||
|
"2026-05-28",
|
||||||
|
"ABC",
|
||||||
|
"HVAC",
|
||||||
|
]
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="required columns"):
|
||||||
|
handler._build_snapshot(bad_header, rows)
|
||||||
|
|
||||||
|
def test_is_escalation_and_is_action_derived(self):
|
||||||
|
import classify as clf
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-020",
|
||||||
|
wo_status="H",
|
||||||
|
hold_reason="REPORT",
|
||||||
|
last_comment="<html>3rd attempt process for schedule confirmation.</html>",
|
||||||
|
),
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-021",
|
||||||
|
wo_status="IP",
|
||||||
|
hold_reason="",
|
||||||
|
last_comment="<html>WO schedule confirmed with vendor.</html>",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
df, _ = handler._build_snapshot(_HEADER, rows)
|
||||||
|
|
||||||
|
esc_row = df[df["wo_number"] == "WO-020"].iloc[0]
|
||||||
|
assert bool(esc_row["is_escalation"]) is True
|
||||||
|
assert esc_row["category"] in clf.ESCALATION_CATEGORIES
|
||||||
|
|
||||||
|
routine_row = df[df["wo_number"] == "WO-021"].iloc[0]
|
||||||
|
assert bool(routine_row["is_escalation"]) is False
|
||||||
|
assert routine_row["category"] == "Schedule Confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_summary
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSummary:
|
||||||
|
def _make_df(self):
|
||||||
|
"""Build a small DataFrame via _build_snapshot."""
|
||||||
|
rows = [
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-030",
|
||||||
|
wo_status="H",
|
||||||
|
hold_reason="REPORT",
|
||||||
|
last_comment="<html>3rd attempt process for schedule confirmation.</html>",
|
||||||
|
site="ABQ5",
|
||||||
|
),
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-031",
|
||||||
|
wo_status="H",
|
||||||
|
hold_reason="REPORT",
|
||||||
|
last_comment="<html>3rd attempt process for schedule confirmation.</html>",
|
||||||
|
site="ACY9",
|
||||||
|
),
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-032",
|
||||||
|
wo_status="IP",
|
||||||
|
hold_reason="",
|
||||||
|
last_comment="<html>WO schedule confirmed with vendor.</html>",
|
||||||
|
site="ABQ5",
|
||||||
|
),
|
||||||
|
# This row produces a mismatch: completion comment + IP status
|
||||||
|
_make_row(
|
||||||
|
wo_number="WO-033",
|
||||||
|
wo_status="IP",
|
||||||
|
hold_reason="REPORT",
|
||||||
|
last_comment="<html>Vendor arrived and performed task.</html>",
|
||||||
|
site="ABQ5",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
df, blank = handler._build_snapshot(_HEADER, rows)
|
||||||
|
return df, blank
|
||||||
|
|
||||||
|
def test_third_escalation_count(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert summary["third_escalation_count"] == 2
|
||||||
|
|
||||||
|
def test_category_counts_present(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert "3rd Escalation" in summary["category_counts"]
|
||||||
|
assert summary["category_counts"]["3rd Escalation"] == 2
|
||||||
|
|
||||||
|
def test_escalation_total(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert summary["escalation_total"] == 2
|
||||||
|
|
||||||
|
def test_action_needed_and_routine_sum_to_classified_total(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert (
|
||||||
|
summary["action_needed"] + summary["routine"] == summary["classified_total"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_top_sites_shape(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert isinstance(summary["top_sites"], list)
|
||||||
|
for entry in summary["top_sites"]:
|
||||||
|
assert "site" in entry
|
||||||
|
assert "count" in entry
|
||||||
|
# ABQ5 appears 3 times, should be first
|
||||||
|
assert summary["top_sites"][0]["site"] == "ABQ5"
|
||||||
|
|
||||||
|
def test_mismatches_list(self):
|
||||||
|
df, blank = self._make_df()
|
||||||
|
summary = handler._build_summary(df, "2026-05-28", "raw/export.csv", blank)
|
||||||
|
assert isinstance(summary["mismatches"], list)
|
||||||
|
# WO-033 has a mismatch (completion comment + REPORT hold)
|
||||||
|
assert len(summary["mismatches"]) >= 1
|
||||||
|
mismatch_wos = [m["wo_number"] for m in summary["mismatches"]]
|
||||||
|
assert "WO-033" in mismatch_wos
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _event_dt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEventDt:
|
||||||
|
def test_event_time_extracted(self):
|
||||||
|
record = {"eventTime": "2026-05-28T22:23:40.123Z"}
|
||||||
|
assert handler._event_dt(record) == "2026-05-28"
|
||||||
|
|
||||||
|
def test_no_event_time_falls_back_to_today(self):
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
record = {}
|
||||||
|
result = handler._event_dt(record)
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
# Result must look like a YYYY-MM-DD date string
|
||||||
|
assert len(result) == 10
|
||||||
|
assert result[4] == "-" and result[7] == "-"
|
||||||
|
assert result == today
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# handler() entrypoint — two S3 records, monkeypatched AWS boundaries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandlerEntrypoint:
|
||||||
|
def _build_csv_bytes(
|
||||||
|
self,
|
||||||
|
wo_status="IP",
|
||||||
|
last_comment="<html>Schedule confirmed with vendor.</html>",
|
||||||
|
):
|
||||||
|
"""Return CSV bytes for a single-row export."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(_HEADER)
|
||||||
|
writer.writerow(_make_row(wo_status=wo_status, last_comment=last_comment))
|
||||||
|
return buf.getvalue().encode("utf-8")
|
||||||
|
|
||||||
|
def test_two_records_processed_slack_invoked_once_per_dt(
|
||||||
|
self, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
csv_bytes_a = self._build_csv_bytes()
|
||||||
|
csv_bytes_b = self._build_csv_bytes(
|
||||||
|
last_comment="<html>3rd attempt process for schedule confirmation.</html>"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track put_object and lambda.invoke calls
|
||||||
|
put_calls: list[dict] = []
|
||||||
|
invoke_calls: list[dict] = []
|
||||||
|
|
||||||
|
def fake_download_fileobj(bucket, key, fh):
|
||||||
|
if "file_a" in key:
|
||||||
|
fh.write(csv_bytes_a)
|
||||||
|
else:
|
||||||
|
fh.write(csv_bytes_b)
|
||||||
|
|
||||||
|
fake_s3 = MagicMock()
|
||||||
|
fake_s3.download_fileobj.side_effect = fake_download_fileobj
|
||||||
|
fake_s3.put_object.side_effect = lambda **kw: put_calls.append(kw)
|
||||||
|
|
||||||
|
fake_lambda = MagicMock()
|
||||||
|
fake_lambda.invoke.side_effect = lambda **kw: invoke_calls.append(kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(handler, "_s3", fake_s3)
|
||||||
|
monkeypatch.setattr(handler, "_lambda", fake_lambda)
|
||||||
|
monkeypatch.setattr(handler.wr.s3, "to_parquet", MagicMock())
|
||||||
|
|
||||||
|
# Set the SLACK_POST_FUNCTION_NAME env var so the lambda invoke fires
|
||||||
|
monkeypatch.setenv("SLACK_POST_FUNCTION_NAME", "apm-slack-post")
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"eventTime": "2026-05-28T10:00:00.000Z",
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {"key": "raw/file_a.csv"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eventTime": "2026-05-28T11:00:00.000Z",
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {"key": "raw/file_b.csv"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = handler.handler(event, None)
|
||||||
|
|
||||||
|
# Both records processed
|
||||||
|
assert len(result["processed"]) == 2
|
||||||
|
|
||||||
|
# summary.json and details.json written for each record (2 put_object calls each = 4)
|
||||||
|
assert len(put_calls) == 4
|
||||||
|
|
||||||
|
# Slack post invoked exactly once (both records share the same dt "2026-05-28")
|
||||||
|
assert len(invoke_calls) == 1
|
||||||
|
assert invoke_calls[0]["FunctionName"] == "apm-slack-post"
|
||||||
|
payload = json.loads(invoke_calls[0]["Payload"])
|
||||||
|
assert payload["dt"] == "2026-05-28"
|
||||||
|
|
||||||
|
def test_non_export_key_skipped(self, monkeypatch):
|
||||||
|
fake_s3 = MagicMock()
|
||||||
|
fake_lambda = MagicMock()
|
||||||
|
monkeypatch.setattr(handler, "_s3", fake_s3)
|
||||||
|
monkeypatch.setattr(handler, "_lambda", fake_lambda)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"Records": [
|
||||||
|
{
|
||||||
|
"eventTime": "2026-05-28T10:00:00.000Z",
|
||||||
|
"s3": {
|
||||||
|
"bucket": {"name": "test-bucket"},
|
||||||
|
"object": {"key": "raw/not-an-export.txt"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = handler.handler(event, None)
|
||||||
|
assert result["processed"] == []
|
||||||
|
fake_s3.download_fileobj.assert_not_called()
|
||||||
|
|
@ -9,9 +9,13 @@ Canonical fixture: ``~/Downloads/_documents/Sheet1-1.xlsx`` (347 rows, 13 cols).
|
||||||
If the file is absent the export-driven tests skip. Run with the repo venv:
|
If the file is absent the export-driven tests skip. Run with the repo venv:
|
||||||
|
|
||||||
./.venv/bin/python -m pytest tests/test_classify.py -s -q
|
./.venv/bin/python -m pytest tests/test_classify.py -s -q
|
||||||
|
|
||||||
|
CSV fixture (always present, runs in CI): ``tests/fixtures/sample_export.csv``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
|
import csv
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -30,10 +34,31 @@ COL_LAST_COMMENT = 8
|
||||||
|
|
||||||
FIXTURE = Path.home() / "Downloads" / "_documents" / "Sheet1-1.xlsx"
|
FIXTURE = Path.home() / "Downloads" / "_documents" / "Sheet1-1.xlsx"
|
||||||
|
|
||||||
|
# Committed CSV fixture — always present, no skipif.
|
||||||
|
CSV_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "sample_export.csv"
|
||||||
|
|
||||||
# Max acceptable deterministic "Other" share before any AI. CLAUDE.md: the
|
# Max acceptable deterministic "Other" share before any AI. CLAUDE.md: the
|
||||||
# two-axis model lands ~9% before Haiku; we hold the line at single digits.
|
# two-axis model lands ~9% before Haiku; we hold the line at single digits.
|
||||||
MAX_OTHER_PCT = 10.0
|
MAX_OTHER_PCT = 10.0
|
||||||
|
|
||||||
|
# Threshold for the committed CSV fixture. Measured deterministic Other% on
|
||||||
|
# the synthetic fixture: 7.41% (2 of 27 classified rows). Threshold is set
|
||||||
|
# with headroom but still comfortably single-digit.
|
||||||
|
CSV_FIXTURE_MAX_OTHER_PCT = 9.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV loader helper (yields column-indexed tuples like openpyxl row values)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _load_csv_rows(path: Path) -> list[tuple]:
|
||||||
|
"""Read a 13-column CSV export; return data rows as tuples (header skipped)."""
|
||||||
|
with path.open(newline="") as fh:
|
||||||
|
reader = csv.reader(fh)
|
||||||
|
rows = list(reader)
|
||||||
|
return [tuple(r) for r in rows[1:]] # drop header row
|
||||||
|
|
||||||
|
|
||||||
def test_classification_constants_well_formed():
|
def test_classification_constants_well_formed():
|
||||||
assert "3rd Escalation" in classify.ESCALATION_CATEGORIES
|
assert "3rd Escalation" in classify.ESCALATION_CATEGORIES
|
||||||
|
|
@ -244,3 +269,163 @@ def test_known_fixture_rows_in_export():
|
||||||
report_completion_mismatch[COL_LAST_COMMENT],
|
report_completion_mismatch[COL_LAST_COMMENT],
|
||||||
)
|
)
|
||||||
assert mm is not None
|
assert mm is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV fixture tests — always run (no skipif), so the quality gate fires in CI.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_share_against_csv_fixture(capsys):
|
||||||
|
"""Classification quality gate against the committed synthetic CSV fixture.
|
||||||
|
|
||||||
|
Measured deterministic Other%: 7.41% (2/27). Threshold: 9.0%.
|
||||||
|
This test runs unconditionally in CI.
|
||||||
|
"""
|
||||||
|
rows = _load_csv_rows(CSV_FIXTURE)
|
||||||
|
|
||||||
|
dist: collections.Counter = collections.Counter()
|
||||||
|
mismatches = 0
|
||||||
|
classified = 0
|
||||||
|
blank = 0
|
||||||
|
other_samples: list[str] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
comment = row[COL_LAST_COMMENT] if len(row) > COL_LAST_COMMENT else ""
|
||||||
|
# Blank-comment rows are excluded from the classified total by design.
|
||||||
|
if comment is None or str(comment).strip() == "":
|
||||||
|
blank += 1
|
||||||
|
continue
|
||||||
|
classified += 1
|
||||||
|
category, mismatch = classify.classify(
|
||||||
|
row[COL_WO_STATUS], row[COL_HOLD_REASON], comment
|
||||||
|
)
|
||||||
|
dist[category] += 1
|
||||||
|
if mismatch:
|
||||||
|
mismatches += 1
|
||||||
|
if category == "Other" and len(other_samples) < 20:
|
||||||
|
other_samples.append(classify.strip_html(comment)[:90])
|
||||||
|
|
||||||
|
other = dist["Other"]
|
||||||
|
other_pct = other * 100.0 / classified if classified else 0.0
|
||||||
|
|
||||||
|
with capsys.disabled():
|
||||||
|
print(f"\n=== APM classifier smoke test (CSV fixture): {CSV_FIXTURE.name} ===")
|
||||||
|
print(
|
||||||
|
f"rows={len(rows)} classified={classified} "
|
||||||
|
f"blank-excluded={blank} (blank-comment rows excluded from the total)"
|
||||||
|
)
|
||||||
|
print("--- category distribution ---")
|
||||||
|
for cat, count in dist.most_common():
|
||||||
|
print(f" {count:4d} {count * 100.0 / classified:5.1f}% {cat}")
|
||||||
|
print(f"--- Other: {other} ({other_pct:.2f}%) ---")
|
||||||
|
for sample in other_samples:
|
||||||
|
print(f" [Other] {sample}")
|
||||||
|
print(f"--- mismatches flagged: {mismatches} ---")
|
||||||
|
|
||||||
|
assert classified > 0, "CSV fixture produced no classified rows"
|
||||||
|
assert other_pct <= CSV_FIXTURE_MAX_OTHER_PCT, (
|
||||||
|
f"deterministic Other {other_pct:.2f}% exceeds {CSV_FIXTURE_MAX_OTHER_PCT}% — "
|
||||||
|
"the two-axis ladder regressed against the committed fixture"
|
||||||
|
)
|
||||||
|
# Fixture must exercise mismatch detection (WO-1012: REPORT hold + performed task).
|
||||||
|
assert mismatches >= 1, "CSV fixture should contain at least one mismatch row"
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_rows_in_csv_fixture():
|
||||||
|
"""Anchor checks on the synthetic CSV fixture — runs unconditionally in CI."""
|
||||||
|
rows = _load_csv_rows(CSV_FIXTURE)
|
||||||
|
|
||||||
|
by_status: collections.defaultdict = collections.defaultdict(list)
|
||||||
|
schedule_confirmed_row = None
|
||||||
|
report_completion_mismatch = None
|
||||||
|
third_esc_rows: list[tuple] = []
|
||||||
|
cancelled_rows: list[tuple] = []
|
||||||
|
structured_report_rows: list[tuple] = []
|
||||||
|
structured_scheduling_rows: list[tuple] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
comment = row[COL_LAST_COMMENT] if len(row) > COL_LAST_COMMENT else ""
|
||||||
|
if comment is None or str(comment).strip() == "":
|
||||||
|
continue
|
||||||
|
text = classify.strip_html(comment)
|
||||||
|
by_status[row[COL_WO_STATUS]].append(row)
|
||||||
|
|
||||||
|
if (
|
||||||
|
schedule_confirmed_row is None
|
||||||
|
and "schedule confirmed with vendor" in text.lower()
|
||||||
|
and not (row[COL_HOLD_REASON] or "").strip()
|
||||||
|
):
|
||||||
|
schedule_confirmed_row = row
|
||||||
|
if (
|
||||||
|
report_completion_mismatch is None
|
||||||
|
and (row[COL_HOLD_REASON] or "").strip().upper() == "REPORT"
|
||||||
|
and "performed task" in text.lower()
|
||||||
|
):
|
||||||
|
report_completion_mismatch = row
|
||||||
|
if re.search(r"\b3rd\b.*\battempt\b", text.lower()):
|
||||||
|
third_esc_rows.append(row)
|
||||||
|
if row[COL_WO_STATUS] == "RCAN":
|
||||||
|
cancelled_rows.append(row)
|
||||||
|
# Structured-only: HTML-wrapped empty comment (strips to "") with REPORT hold.
|
||||||
|
if (row[COL_HOLD_REASON] or "").strip().upper() == "REPORT" and text == "":
|
||||||
|
structured_report_rows.append(row)
|
||||||
|
if (row[COL_HOLD_REASON] or "").strip().upper() == "SCHEDULING" and text == "":
|
||||||
|
structured_scheduling_rows.append(row)
|
||||||
|
|
||||||
|
# "WO schedule confirmed with vendor" → Schedule Confirmed.
|
||||||
|
assert schedule_confirmed_row is not None, "fixture lacks a schedule-confirmed row"
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
schedule_confirmed_row[COL_WO_STATUS],
|
||||||
|
schedule_confirmed_row[COL_HOLD_REASON],
|
||||||
|
schedule_confirmed_row[COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert cat == "Schedule Confirmed", f"expected Schedule Confirmed, got {cat!r}"
|
||||||
|
|
||||||
|
# RCAN rows → Cancelled.
|
||||||
|
assert cancelled_rows, "fixture lacks an RCAN row"
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
cancelled_rows[0][COL_WO_STATUS],
|
||||||
|
cancelled_rows[0][COL_HOLD_REASON],
|
||||||
|
cancelled_rows[0][COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert cat == "Cancelled", f"expected Cancelled, got {cat!r}"
|
||||||
|
|
||||||
|
# 3rd Escalation rows are present and classify correctly.
|
||||||
|
assert third_esc_rows, "fixture lacks a 3rd-escalation row"
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
third_esc_rows[0][COL_WO_STATUS],
|
||||||
|
third_esc_rows[0][COL_HOLD_REASON],
|
||||||
|
third_esc_rows[0][COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert cat == "3rd Escalation", f"expected 3rd Escalation, got {cat!r}"
|
||||||
|
|
||||||
|
# Structured-only REPORT rows → Report / Docs Needed.
|
||||||
|
assert structured_report_rows, "fixture lacks a structured-only REPORT hold row"
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
structured_report_rows[0][COL_WO_STATUS],
|
||||||
|
structured_report_rows[0][COL_HOLD_REASON],
|
||||||
|
structured_report_rows[0][COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert cat == "Report / Docs Needed", f"expected Report / Docs Needed, got {cat!r}"
|
||||||
|
|
||||||
|
# Structured-only SCHEDULING rows → Awaiting Scheduling.
|
||||||
|
assert structured_scheduling_rows, "fixture lacks a structured-only SCHEDULING row"
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
structured_scheduling_rows[0][COL_WO_STATUS],
|
||||||
|
structured_scheduling_rows[0][COL_HOLD_REASON],
|
||||||
|
structured_scheduling_rows[0][COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert cat == "Awaiting Scheduling", f"expected Awaiting Scheduling, got {cat!r}"
|
||||||
|
|
||||||
|
# REPORT-hold row with completion comment → mismatch surfaced.
|
||||||
|
assert report_completion_mismatch is not None, (
|
||||||
|
"fixture lacks a REPORT-hold row with a completion comment (mismatch case)"
|
||||||
|
)
|
||||||
|
_, mm = classify.classify(
|
||||||
|
report_completion_mismatch[COL_WO_STATUS],
|
||||||
|
report_completion_mismatch[COL_HOLD_REASON],
|
||||||
|
report_completion_mismatch[COL_LAST_COMMENT],
|
||||||
|
)
|
||||||
|
assert mm is not None, "expected a mismatch reason for WO-1012 but got None"
|
||||||
|
assert "REPORT" in mm, f"mismatch reason should mention REPORT hold: {mm!r}"
|
||||||
|
|
|
||||||
284
tests/test_comment_intent.py
Normal file
284
tests/test_comment_intent.py
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
"""Per-bucket and precedence tests for classify.comment_intent().
|
||||||
|
|
||||||
|
Tests operate on *already-stripped* plain text (as comment_intent() expects).
|
||||||
|
End-to-end precedence is also verified via classify.classify(), which handles
|
||||||
|
HTML stripping internally.
|
||||||
|
|
||||||
|
Bucket coverage:
|
||||||
|
- One positive case per ladder bucket that can fire deterministically:
|
||||||
|
3rd Escalation, 2nd Escalation, 1st Escalation, SIM Ticket, Vendor No-Show,
|
||||||
|
Weekly WO Scheduled, Avetta Project Created, Schedule Confirmed,
|
||||||
|
Completed / Pending Close, Rescheduled, Report / Docs Needed,
|
||||||
|
Awaiting Report / Invoice, Awaiting Scheduling, Status Inquiry, On Hold,
|
||||||
|
Acknowledgement / No-op.
|
||||||
|
- "Other Escalation" has no dedicated regex rule in the ladder and is
|
||||||
|
intentionally omitted (it is a Haiku-only bucket).
|
||||||
|
|
||||||
|
Precedence / negative traps:
|
||||||
|
- 3rd attempt + report language -> 3rd Escalation (most-specific wins).
|
||||||
|
- 1st attempt process for schedule confirmation -> Awaiting Scheduling
|
||||||
|
(not an escalation; the ladder requires an explicit escalation signal).
|
||||||
|
- Bare "1st attempt to contact vendor" (no escalation keyword) -> None.
|
||||||
|
- Empty / whitespace -> None.
|
||||||
|
- "Copy" / "Copy 5/26." -> Acknowledgement / No-op (fullmatch).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(
|
||||||
|
0, str(Path(__file__).resolve().parent.parent / "lambdas" / "classifier")
|
||||||
|
)
|
||||||
|
|
||||||
|
import classify # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Axis-1 per-bucket positive cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommentIntentBuckets:
|
||||||
|
def test_3rd_escalation_attempt(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("3rd attempt process for schedule confirmation")
|
||||||
|
== "3rd Escalation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_3rd_escalation_explicit(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("3rd escalation sent to vendor") == "3rd Escalation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_2nd_escalation_attempt(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("2nd attempt process for schedule confirmation")
|
||||||
|
== "2nd Escalation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_2nd_escalation_explicit(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("2nd escalation sent to vendor") == "2nd Escalation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_1st_escalation_explicit(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("1st escalation sent to management")
|
||||||
|
== "1st Escalation"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sim_ticket_with_version(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("SIM ticket v1234567 created for this WO")
|
||||||
|
== "SIM Ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sim_ticket_tcorp_url(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("SIM TT opened t.corp.amazon.com/issues/123")
|
||||||
|
== "SIM Ticket"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_vendor_no_show(self):
|
||||||
|
assert classify.comment_intent("Vendor was a no show today") == "Vendor No-Show"
|
||||||
|
|
||||||
|
def test_weekly_wo_scheduled(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent(
|
||||||
|
"Weekly WO scheduled. Service reports required EOD Friday."
|
||||||
|
)
|
||||||
|
== "Weekly WO Scheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_avetta_project_created(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Avetta project created for this work")
|
||||||
|
== "Avetta Project Created"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_avetta_work_request(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Work request created in Avetta for this job")
|
||||||
|
== "Avetta Project Created"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_confirmed_with_vendor(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Schedule confirmed with vendor for next week")
|
||||||
|
== "Schedule Confirmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_schedule_confirmed_vendor_confirmed(self):
|
||||||
|
assert classify.comment_intent("Vendor confirmed") == "Schedule Confirmed"
|
||||||
|
|
||||||
|
def test_completed_pending_close_performed_task(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Vendor arrived and performed task")
|
||||||
|
== "Completed / Pending Close"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completed_pending_close_completed_by(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Completed by contractor on Monday")
|
||||||
|
== "Completed / Pending Close"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completed_pending_close_cant_close(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Can't close the WO") == "Completed / Pending Close"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completed_pending_close_pending_close(self):
|
||||||
|
assert classify.comment_intent("Pending close") == "Completed / Pending Close"
|
||||||
|
|
||||||
|
def test_rescheduled(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("WO rescheduled to next Tuesday") == "Rescheduled"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rescheduled_new_eta(self):
|
||||||
|
assert classify.comment_intent("New ETA provided by vendor") == "Rescheduled"
|
||||||
|
|
||||||
|
def test_report_docs_needed_service_report(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Please upload service report")
|
||||||
|
== "Report / Docs Needed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_report_docs_needed_completion_report(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Completion report required")
|
||||||
|
== "Report / Docs Needed"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_awaiting_report_invoice_awaiting(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Awaiting the invoice from vendor")
|
||||||
|
== "Awaiting Report / Invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_awaiting_report_invoice_pending(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Pending report from contractor")
|
||||||
|
== "Awaiting Report / Invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_awaiting_scheduling_please_schedule(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent(
|
||||||
|
"Please schedule this WO at your earliest convenience"
|
||||||
|
)
|
||||||
|
== "Awaiting Scheduling"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_status_inquiry_any_update(self):
|
||||||
|
assert classify.comment_intent("Any update on this WO?") == "Status Inquiry"
|
||||||
|
|
||||||
|
def test_status_inquiry_eta(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("Do you have an ETA on this?") == "Status Inquiry"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_on_hold(self):
|
||||||
|
assert (
|
||||||
|
classify.comment_intent("WO is on hold pending budget approval")
|
||||||
|
== "On Hold"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_acknowledgement_copy(self):
|
||||||
|
assert classify.comment_intent("Copy") == "Acknowledgement / No-op"
|
||||||
|
|
||||||
|
def test_acknowledgement_copy_with_date(self):
|
||||||
|
assert classify.comment_intent("Copy 5/26.") == "Acknowledgement / No-op"
|
||||||
|
|
||||||
|
def test_acknowledgement_noted(self):
|
||||||
|
assert classify.comment_intent("Noted") == "Acknowledgement / No-op"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Precedence / negative traps
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommentIntentPrecedence:
|
||||||
|
def test_3rd_attempt_plus_report_language_resolves_to_3rd_escalation(self):
|
||||||
|
"""A comment that mentions both '3rd attempt' and report language must
|
||||||
|
resolve to '3rd Escalation' — the most-specific bucket wins."""
|
||||||
|
text = (
|
||||||
|
"3rd attempt process for schedule confirmation. "
|
||||||
|
"Please provide service report."
|
||||||
|
)
|
||||||
|
assert classify.comment_intent(text) == "3rd Escalation"
|
||||||
|
|
||||||
|
def test_1st_attempt_for_schedule_confirmation_is_awaiting_scheduling(self):
|
||||||
|
"""'1st attempt process for schedule confirmation' is routine outreach,
|
||||||
|
not an escalation. The ladder has an explicit Awaiting Scheduling rule
|
||||||
|
for this canonical phrase."""
|
||||||
|
text = "1st attempt process for schedule confirmation"
|
||||||
|
assert classify.comment_intent(text) == "Awaiting Scheduling"
|
||||||
|
|
||||||
|
def test_bare_1st_attempt_no_escalation_keyword_is_none(self):
|
||||||
|
"""A bare '1st attempt to contact vendor' with no escalation keyword
|
||||||
|
must not fire any escalation bucket — the ladder requires an explicit
|
||||||
|
escalation signal beyond the mere ordinal."""
|
||||||
|
text = "1st attempt to contact vendor"
|
||||||
|
assert classify.comment_intent(text) is None
|
||||||
|
|
||||||
|
def test_empty_string_returns_none(self):
|
||||||
|
assert classify.comment_intent("") is None
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_none(self):
|
||||||
|
assert classify.comment_intent(" ") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# End-to-end precedence via classify()
|
||||||
|
# (strips HTML, then calls comment_intent, then applies structured-state axis)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifyPrecedence:
|
||||||
|
def test_3rd_attempt_html_with_report_hold_resolves_to_3rd_escalation(self):
|
||||||
|
"""Even when REPORT hold is set, a 3rd-attempt comment must resolve to
|
||||||
|
'3rd Escalation' because comment intent wins over structured state."""
|
||||||
|
cat, mm = classify.classify(
|
||||||
|
"H",
|
||||||
|
"REPORT",
|
||||||
|
"<html>3rd attempt process for schedule confirmation. "
|
||||||
|
"Please provide service report.</html>",
|
||||||
|
)
|
||||||
|
assert cat == "3rd Escalation"
|
||||||
|
# 3rd Escalation is not in _DONE_INTENTS, so no mismatch is produced.
|
||||||
|
assert mm is None
|
||||||
|
|
||||||
|
def test_1st_attempt_schedule_confirmation_via_classify(self):
|
||||||
|
"""End-to-end: HTML-wrapped '1st attempt process for schedule
|
||||||
|
confirmation' classifies as Awaiting Scheduling."""
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
"IP",
|
||||||
|
"",
|
||||||
|
"<html>1st attempt process for schedule confirmation</html>",
|
||||||
|
)
|
||||||
|
assert cat == "Awaiting Scheduling"
|
||||||
|
|
||||||
|
def test_comment_intent_wins_over_structured_state(self):
|
||||||
|
"""When the comment fires a rule, it overrides the Hold Reason axis."""
|
||||||
|
# Schedule Confirmed comment wins over SCHEDULING hold.
|
||||||
|
cat, _ = classify.classify(
|
||||||
|
"R",
|
||||||
|
"SCHEDULING",
|
||||||
|
"<html>WO schedule confirmed with vendor</html>",
|
||||||
|
)
|
||||||
|
assert cat == "Schedule Confirmed"
|
||||||
|
|
||||||
|
def test_structured_state_used_when_no_comment_intent(self):
|
||||||
|
"""When no Axis-1 rule fires (blank comment), structured state decides."""
|
||||||
|
cat, _ = classify.classify("IP", "REPORT", "<html></html>")
|
||||||
|
assert cat == "Report / Docs Needed"
|
||||||
|
|
||||||
|
def test_other_when_nothing_matches(self):
|
||||||
|
"""Neither comment intent nor structured state → Other."""
|
||||||
|
cat, _ = classify.classify("IP", "", "<html>Uplift request submitted</html>")
|
||||||
|
assert cat == "Other"
|
||||||
271
tests/test_haiku.py
Normal file
271
tests/test_haiku.py
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"""Tests for the Haiku fallback in classify_with_haiku().
|
||||||
|
|
||||||
|
No real network or AWS calls are made — _call_haiku and _fetch_api_key are
|
||||||
|
monkeypatched at the function level. boto3 shape tests stub the secretsmanager
|
||||||
|
client directly via monkeypatch on boto3.client.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import classify
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comment that will always land as "Other" deterministically (free-text with
|
||||||
|
# no structured hold signal and no regex match).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_OTHER_COMMENT = "<html>Uplift request submitted pending management approval.</html>"
|
||||||
|
_TRIVIAL_COMMENT = "<html>Ok</html>" # strips to "Ok" — len == 2 < 4
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _haiku_never_called():
|
||||||
|
"""Return a _call_haiku stub that raises if invoked."""
|
||||||
|
|
||||||
|
def _stub(text, api_key):
|
||||||
|
raise AssertionError("_call_haiku should not have been called")
|
||||||
|
|
||||||
|
return _stub
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gating: Haiku is NOT called when the deterministic result is non-Other
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_deterministic_result_is_non_other(monkeypatch):
|
||||||
|
"""A comment that resolves deterministically must skip the Haiku path."""
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
# "WO schedule confirmed with vendor" → Schedule Confirmed deterministically
|
||||||
|
cat, mm = classify.classify_with_haiku(
|
||||||
|
"IP", "", "<html>WO schedule confirmed with vendor.</html>"
|
||||||
|
)
|
||||||
|
assert cat == "Schedule Confirmed"
|
||||||
|
assert mm is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_hold_reason_present(monkeypatch):
|
||||||
|
"""Hold reason present → structured state handles it; Haiku must not fire."""
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
# REPORT hold with unrecognised comment → hold decides, not Haiku
|
||||||
|
cat, _ = classify.classify_with_haiku(
|
||||||
|
"H", "REPORT", "<html>xyz nothing here</html>"
|
||||||
|
)
|
||||||
|
assert cat == "Report / Docs Needed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_comment_is_trivial(monkeypatch):
|
||||||
|
"""Comment len < 4 after stripping → trivial, skip Haiku."""
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _TRIVIAL_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_disabled_via_env(monkeypatch):
|
||||||
|
"""APM_HAIKU_FALLBACK=off must suppress the Haiku call."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "off")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_disabled_via_zero(monkeypatch):
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "0")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_not_called_when_disabled_via_false(monkeypatch):
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "false")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _haiku_never_called())
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "fake-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Haiku IS invoked (enabled, Other, no hold, non-trivial)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_called_for_true_residual_other(monkeypatch):
|
||||||
|
"""When all gates pass, _call_haiku must be invoked."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "on")
|
||||||
|
haiku_calls: list[tuple] = []
|
||||||
|
|
||||||
|
def _stub_haiku(text, api_key):
|
||||||
|
haiku_calls.append((text, api_key))
|
||||||
|
return "Rescheduled"
|
||||||
|
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _stub_haiku)
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Rescheduled"
|
||||||
|
assert len(haiku_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_valid_bucket_replaces_other(monkeypatch):
|
||||||
|
"""When Haiku returns a valid bucket, that bucket is used."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", lambda text, key: "Rescheduled")
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Rescheduled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_none_response_stays_other(monkeypatch):
|
||||||
|
"""When Haiku returns None, the result stays Other."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", lambda text, key: None)
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_other_response_stays_other(monkeypatch):
|
||||||
|
"""When Haiku returns 'Other', the result stays Other (guard against self-loop)."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", lambda text, key: "Other")
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_haiku_exception_in_call_haiku_stays_other(monkeypatch):
|
||||||
|
"""Exception raised by _call_haiku (e.g. network failure) is caught and
|
||||||
|
the result safely stays at Other."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
|
||||||
|
def _raise(text, api_key):
|
||||||
|
raise OSError("network failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", _raise)
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
cat, _ = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Other"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatch_preserved_through_haiku_path(monkeypatch):
|
||||||
|
"""The deterministic mismatch reason must survive through the Haiku path."""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", lambda text, key: "Rescheduled")
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
# A comment that fires "Completed / Pending Close" intent while on a REPORT hold
|
||||||
|
# produces a mismatch; but the deterministic result is NOT Other here. To test
|
||||||
|
# mismatch preservation we need the deterministic result to be Other while a
|
||||||
|
# mismatch exists. Mismatch is computed from intent vs structured state; if the
|
||||||
|
# intent is None (→ Other from deterministic), no mismatch is produced by
|
||||||
|
# _mismatch(). So the relevant test is: Haiku fires on an Other row; the
|
||||||
|
# mismatch (None in this case) is preserved correctly.
|
||||||
|
cat, mm = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
# Comment has no intent → no mismatch → mm is None; Haiku returns Rescheduled
|
||||||
|
assert cat == "Rescheduled"
|
||||||
|
assert mm is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatch_preserved_when_haiku_overrides(monkeypatch):
|
||||||
|
"""Verify mismatch from classify() passes through unchanged when Haiku overrides.
|
||||||
|
|
||||||
|
We use a real deterministic Other-producing row and manually inject a mismatch
|
||||||
|
by monkeypatching classify.classify to return (Other, "fake mismatch reason").
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("APM_HAIKU_FALLBACK", "1")
|
||||||
|
monkeypatch.setattr(classify, "_call_haiku", lambda text, key: "Rescheduled")
|
||||||
|
monkeypatch.setattr(classify, "_fetch_api_key", lambda: "test-key")
|
||||||
|
|
||||||
|
original_classify = classify.classify
|
||||||
|
|
||||||
|
def _patched_classify(wo_status, hold_reason, last_comment):
|
||||||
|
cat, _ = original_classify(wo_status, hold_reason, last_comment)
|
||||||
|
if cat == "Other":
|
||||||
|
return "Other", "injected mismatch reason"
|
||||||
|
return cat, _
|
||||||
|
|
||||||
|
monkeypatch.setattr(classify, "classify", _patched_classify)
|
||||||
|
|
||||||
|
cat, mm = classify.classify_with_haiku("IP", "", _OTHER_COMMENT)
|
||||||
|
assert cat == "Rescheduled"
|
||||||
|
assert mm == "injected mismatch reason"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _fetch_api_key JSON shapes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_secretsmanager_client(secret_string: str) -> MagicMock:
|
||||||
|
"""Build a minimal secretsmanager client stub returning the given secret."""
|
||||||
|
client = MagicMock()
|
||||||
|
client.get_secret_value.return_value = {"SecretString": secret_string}
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_api_key_bare_string(monkeypatch):
|
||||||
|
"""Bare string secret → returned as-is."""
|
||||||
|
fake_client = _make_secretsmanager_client("sk-ant-barekey123")
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
monkeypatch.setattr(boto3, "client", lambda service, **kw: fake_client)
|
||||||
|
key = classify._fetch_api_key()
|
||||||
|
assert key == "sk-ant-barekey123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_api_key_json_anthropic_api_key(monkeypatch):
|
||||||
|
"""JSON with 'anthropic-api-key' field → that value extracted."""
|
||||||
|
secret = json.dumps({"anthropic-api-key": "sk-ant-jsonkey456"})
|
||||||
|
fake_client = _make_secretsmanager_client(secret)
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
monkeypatch.setattr(boto3, "client", lambda service, **kw: fake_client)
|
||||||
|
key = classify._fetch_api_key()
|
||||||
|
assert key == "sk-ant-jsonkey456"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_api_key_single_value_json(monkeypatch):
|
||||||
|
"""Single-value JSON object with an arbitrary key → the only value extracted."""
|
||||||
|
secret = json.dumps({"my_custom_key": "sk-ant-singleval789"})
|
||||||
|
fake_client = _make_secretsmanager_client(secret)
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
monkeypatch.setattr(boto3, "client", lambda service, **kw: fake_client)
|
||||||
|
key = classify._fetch_api_key()
|
||||||
|
assert key == "sk-ant-singleval789"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_api_key_json_api_key_field(monkeypatch):
|
||||||
|
"""JSON with 'api_key' field → that value extracted."""
|
||||||
|
secret = json.dumps({"api_key": "sk-ant-apikey111"})
|
||||||
|
fake_client = _make_secretsmanager_client(secret)
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
monkeypatch.setattr(boto3, "client", lambda service, **kw: fake_client)
|
||||||
|
key = classify._fetch_api_key()
|
||||||
|
assert key == "sk-ant-apikey111"
|
||||||
331
tests/test_interactions.py
Normal file
331
tests/test_interactions.py
Normal file
|
|
@ -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"]
|
||||||
333
tests/test_slack_post_handler.py
Normal file
333
tests/test_slack_post_handler.py
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
"""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
|
||||||
Loading…
Add table
Reference in a new issue