From a16bf6d37062e7c8c9ea2ff720422dba9c21e57c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:50:27 -0400 Subject: [PATCH] Add pytest suite and wire it into CI Stands up the first automated tests for the repo (151 tests) and turns on the CI test step. - Lift slack-bot handlers out of create_app() closures to module level so they're unit-testable; create_app is now a thin Bolt-wiring layer. No behavior change (handler entrypoints and create_app signature unchanged). - tests/ mirrors src/: shared layer (schedule, blocks, 3CX client, ring_scheduler, secrets) + all four Lambdas (pay math, drop/swap/pick/ admin/register/rate, pickup button, roster sync, queue scheduler). - All boundaries mocked: DynamoDB/SES/Secrets via moto, 3CX HTTP via responses, Slack via fakes, time via freezegun. No real network/AWS. - pyproject.toml pytest config (pythonpath=src/shared, importlib mode); per-package conftest loads each app.py under a unique name to avoid the four-app.py collision. tests/requirements.txt for test-only deps. - ci.yaml: run-tests: true (reusable workflow auto-installs deps) and lint the tests dir too. - README Testing section. Closes #85 --- .github/workflows/ci.yaml | 3 +- .gitignore | 2 + README.md | 20 + pyproject.toml | 7 + src/slack-bot/app.py | 1268 +++++++++--------- tests/conftest.py | 116 ++ tests/requirements.txt | 7 + tests/ring_scheduler/conftest.py | 22 + tests/ring_scheduler/test_handler.py | 61 + tests/roster_sync/conftest.py | 22 + tests/roster_sync/test_handler.py | 91 ++ tests/shared/test_blocks.py | 120 ++ tests/shared/test_ring_scheduler.py | 43 + tests/shared/test_schedule.py | 220 +++ tests/shared/test_secrets.py | 32 + tests/shared/test_three_cx_client.py | 113 ++ tests/slack_bot/conftest.py | 53 + tests/slack_bot/test_handle_admin.py | 90 ++ tests/slack_bot/test_handle_drop.py | 89 ++ tests/slack_bot/test_handle_pick.py | 55 + tests/slack_bot/test_handle_register_rate.py | 64 + tests/slack_bot/test_handle_swap.py | 98 ++ tests/slack_bot/test_helpers.py | 63 + tests/slack_bot/test_parse_date.py | 59 + tests/slack_bot/test_pickup_button.py | 73 + tests/weekly_post/conftest.py | 22 + tests/weekly_post/test_handler.py | 76 ++ tests/weekly_post/test_pay_math.py | 83 ++ 28 files changed, 2348 insertions(+), 624 deletions(-) create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/requirements.txt create mode 100644 tests/ring_scheduler/conftest.py create mode 100644 tests/ring_scheduler/test_handler.py create mode 100644 tests/roster_sync/conftest.py create mode 100644 tests/roster_sync/test_handler.py create mode 100644 tests/shared/test_blocks.py create mode 100644 tests/shared/test_ring_scheduler.py create mode 100644 tests/shared/test_schedule.py create mode 100644 tests/shared/test_secrets.py create mode 100644 tests/shared/test_three_cx_client.py create mode 100644 tests/slack_bot/conftest.py create mode 100644 tests/slack_bot/test_handle_admin.py create mode 100644 tests/slack_bot/test_handle_drop.py create mode 100644 tests/slack_bot/test_handle_pick.py create mode 100644 tests/slack_bot/test_handle_register_rate.py create mode 100644 tests/slack_bot/test_handle_swap.py create mode 100644 tests/slack_bot/test_helpers.py create mode 100644 tests/slack_bot/test_parse_date.py create mode 100644 tests/slack_bot/test_pickup_button.py create mode 100644 tests/weekly_post/conftest.py create mode 100644 tests/weekly_post/test_handler.py create mode 100644 tests/weekly_post/test_pay_math.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ab8ea48..8ff7605 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,4 +7,5 @@ jobs: ci: uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main with: - source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/shared" + source-dirs: "src/slack-bot src/weekly-post src/roster-sync src/ring-scheduler src/shared/shared tests" + run-tests: true diff --git a/.gitignore b/.gitignore index 80fccd1..f697ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ __pycache__/ *.pyc .aws-sam/ venv/ +.venv/ +.pytest_cache/ .env samconfig.toml output.json diff --git a/README.md b/README.md index 6e5dc17..bfe0d93 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ src/ roster-sync/ Daily 3CX roster sync ring-scheduler/ 3CX queue routing updates shared/ Lambda Layer (schedule, blocks, 3CX client, secrets) +tests/ pytest suite (mirrors src/, one dir per Lambda + shared) ``` ### DynamoDB Schema @@ -103,4 +104,23 @@ sam build sam deploy ``` +## Testing + +Unit tests use `pytest` with all external boundaries mocked — DynamoDB / SES / +Secrets Manager via `moto`, 3CX HTTP via `responses`, Slack via fakes, and time +via `freezegun`. No test touches the network or real AWS. + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r tests/requirements.txt # test-only deps +pip install -r src/slack-bot/requirements.txt \ + -r src/weekly-post/requirements.txt \ + -r src/shared/requirements.txt # runtime deps the imports need +pytest +``` + +Each Lambda has its own `app.py`, so the per-package `conftest.py` loads each one +under a unique module name (importlib mode) to avoid collisions. CI runs the same +suite on every PR via the org `ci-python-sam` workflow (`run-tests: true`). + See [SETUP.md](SETUP.md) for full deployment and Slack app creation instructions. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ea7077 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.pytest.ini_options] +# `src/shared` on the path makes the `shared` layer package importable as it is at +# runtime. Each Lambda's own `app.py` is loaded under a unique name by the +# per-package conftest (importlib mode) to avoid the four-`app.py` collision. +pythonpath = ["src/shared"] +testpaths = ["tests"] +addopts = "--import-mode=importlib" diff --git a/src/slack-bot/app.py b/src/slack-bot/app.py index 2ee82ea..0c97891 100644 --- a/src/slack-bot/app.py +++ b/src/slack-bot/app.py @@ -1,4 +1,10 @@ -"""Slack Bolt app — /oncall command handlers and interactive actions.""" +"""Slack Bolt app — /oncall command handlers and interactive actions. + +The handler functions are module-level (not closures over ``create_app``) so they +can be unit-tested directly. ``schedule`` (a ``ShiftSchedule``) and +``schedule_channel`` are threaded through as explicit parameters. ``create_app`` +is a thin wiring layer that registers the Bolt routes and delegates to them. +""" import logging import os @@ -122,6 +128,642 @@ def _find_employee_shift(schedule, date_str, day_name, employee_ext): return None +def _refresh_schedule_post(schedule, schedule_channel, client): + """Update the pinned schedule message in-place after a shift change.""" + channel = schedule_channel + if not channel: + return + post = schedule.get_schedule_post(channel) + if not post or not post.get("message_ts"): + return + try: + blocks = build_week_schedule(schedule) + now = datetime.now(EASTERN) + this_monday = now - timedelta(days=now.weekday()) + end_date = this_monday + timedelta(days=13) + client.chat_update( + channel=channel, + ts=post["message_ts"], + blocks=blocks, + text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", + ) + except Exception: + logger.warning("Could not update schedule post", exc_info=True) + + +# ── Command dispatch ──────────────────────────────────────────────────── + + +def dispatch_oncall(command, respond, client, schedule, schedule_channel): + """Route a /oncall slash command to the appropriate subcommand handler.""" + text = (command.get("text") or "").strip() + user_id = command["user_id"] + channel_id = command["channel_id"] + + is_admin = user_id in schedule.get_admin_users() + post_channel = schedule_channel or channel_id + + if not text or text == "schedule": + _show_schedule(respond, schedule) + elif text == "next": + _show_next_week(respond, schedule) + elif text == "help": + respond(blocks=build_help_blocks(is_admin=is_admin)) + elif text == "roster": + respond(blocks=build_roster_blocks(schedule.get_roster())) + elif text == "pay": + _show_pay(respond, schedule) + elif text.startswith("rate"): + _handle_rate(respond, schedule, text) + elif text.startswith("register"): + _handle_register(respond, schedule, user_id, text) + elif text.startswith("pick"): + _handle_pick( + respond, schedule, user_id, text, post_channel, client, schedule_channel + ) + elif text.startswith("drop"): + _handle_drop( + respond, schedule, user_id, text, post_channel, client, schedule_channel + ) + elif text.startswith("swap"): + _handle_swap( + respond, schedule, user_id, text, post_channel, client, schedule_channel + ) + elif text.startswith("admin"): + _handle_admin( + respond, schedule, user_id, text, is_admin, client, schedule_channel + ) + else: + respond(text="Unknown command. Try `/oncall help`") + + +def handle_pickup(body, respond, client, schedule, schedule_channel): + """Handle the interactive "pick up open shift" button.""" + action_id = body["actions"][0]["action_id"] + remainder = action_id.replace("pickup_", "") + if remainder.endswith("_day"): + date_str = remainder[:-4] + shift_type = "day" + else: + date_str = remainder + shift_type = "night" + + user_id = body["user"]["id"] + channel_id = body["channel"]["id"] + + today_str = datetime.now(EASTERN).strftime("%Y-%m-%d") + if date_str < today_str: + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text="That shift has already passed and can't be picked up.", + ) + return + + employee = schedule.get_employee_by_slack_id(user_id) + if not employee: + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text="You're not registered. Use `/oncall register ` first.", + ) + return + + claimed = schedule.claim_open_shift( + date_str, employee["extension"], employee["name"], shift_type + ) + if not claimed: + client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text=f"That shift on *{date_str}* was already picked up by someone else.", + ) + return + + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(employee["extension"]) + + blocks = build_shift_change_message( + user_id, + date_str, + "picked_up", + employee["extension"], + employee["name"], + shift_type=shift_type, + ) + respond( + response_type="in_channel", + replace_original=False, + blocks=blocks, + text=f"Shift picked up for {date_str}", + ) + _refresh_schedule_post(schedule, schedule_channel, client) + + +# ── Subcommand handlers ───────────────────────────────────────────────── + + +def _show_schedule(respond, schedule): + blocks = build_week_schedule(schedule) + respond(blocks=blocks) + + +def _show_next_week(respond, schedule): + now = datetime.now(EASTERN) + # Jump 2 weeks ahead from this week's Monday + this_monday = now - timedelta(days=now.weekday()) + next_start = this_monday + timedelta(days=14) + blocks = build_week_schedule(schedule, start_date=next_start) + respond(blocks=blocks) + + +def _show_pay(respond, schedule): + now = datetime.now(EASTERN) + # Show last completed week's pay (previous Monday–Sunday) + this_monday = now - timedelta(days=now.weekday()) + prev_monday = this_monday - timedelta(days=7) + week_key = prev_monday.strftime("%Y-%m-%d") + + pay_record = schedule.get_pay_record(week_key) + if pay_record and pay_record.get("breakdown"): + prev_sunday = prev_monday + timedelta(days=6) + week_label = ( + f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}" + ) + blocks = build_pay_summary_blocks( + week_label, pay_record["breakdown"], pay_record["totals"] + ) + respond(blocks=blocks) + else: + respond( + text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}." + ) + + +def _handle_rate(respond, schedule, text): + parts = text.split() + # /oncall rate — show current rates + if len(parts) == 1: + default_rate = schedule.get_shift_rate() + roster = schedule.get_roster() + lines = [f"*Default rate:* ${default_rate:.2f}/shift\n"] + custom = [ + (e["SK"], e.get("name", "Unknown"), float(e["shift_rate"])) + for e in roster + if e.get("shift_rate") + ] + if custom: + lines.append("*Per-person rates:*") + for ext, name, rate in sorted(custom, key=lambda x: x[0]): + lines.append(f"• {name} (Ext {ext}) — ${rate:.2f}/shift") + else: + lines.append("_No per-person rates set — everyone uses the default._") + respond(text="\n".join(lines)) + return + + # /oncall rate default + if parts[1] == "default": + if len(parts) < 3: + respond( + text="Usage: `/oncall rate default ` (e.g. `/oncall rate default 50`)" + ) + return + try: + amount = float(parts[2].replace("$", "")) + except ValueError: + respond(text=f"Invalid amount: `{parts[2]}`") + return + schedule.set_default_shift_rate(amount) + respond(text=f"Default shift rate set to *${amount:.2f}*.") + return + + # /oncall rate + if len(parts) < 3: + respond( + text="Usage: `/oncall rate ` (e.g. `/oncall rate 114 75`)" + ) + return + + ext = parts[1] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + + try: + amount = float(parts[2].replace("$", "")) + except ValueError: + respond(text=f"Invalid amount: `{parts[2]}`") + return + + schedule.set_employee_shift_rate(ext, amount) + respond( + text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*." + ) + + +def _handle_register(respond, schedule, user_id, text): + parts = text.split() + if len(parts) < 2: + respond( + text="Usage: `/oncall register ` (e.g. `/oncall register 114`)" + ) + return + + ext = parts[1].strip() + employee = schedule.register_user(user_id, ext) + if not employee: + respond( + text=f"Extension {ext} not found in the roster. Check `/oncall roster`." + ) + return + + respond(text=f"Linked your account to *{employee['name']}* (Ext {ext}).") + + +def _handle_pick( + respond, schedule, user_id, text, channel_id, client, schedule_channel +): + parts = text.split() + if len(parts) < 2: + respond( + text="Usage: `/oncall pick [day|night]` (e.g. `/oncall pick friday`)" + ) + return + + employee = schedule.get_employee_by_slack_id(user_id) + if not employee: + respond(text="You're not registered. Use `/oncall register ` first.") + return + + explicit_shift = ( + parts[2] if len(parts) > 2 and parts[2] in ("day", "night") else None + ) + date_text = parts[1] + date = parse_date(date_text) + if not date: + respond( + text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 2026-04-05" + ) + return + + date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't pick up a shift in the past.") + return + + day_name = date.strftime("%A") + + if explicit_shift: + shift_type = explicit_shift + ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type) + elif day_name in WEEKEND_DAYS: + shift_type = "night" + for st in ("day", "night"): + ext, name, source = schedule.resolve_shift(date_str, day_name, st) + if source == "available": + shift_type = st + break + else: + ext, name, source = schedule.resolve_shift(date_str, day_name) + else: + shift_type = "night" + ext, name, source = schedule.resolve_shift(date_str, day_name) + + # Already assigned to someone else (not open) + if source in ("weekly", "override") and ext != FALLBACK_EXTENSION: + respond( + text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first." + ) + return + + schedule.set_override(date_str, employee["extension"], employee["name"], shift_type) + + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(employee["extension"]) + + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) + respond(text=f"You picked up the shift for *{date_label}*{shift_label}.") + + blocks = build_shift_change_message( + user_id, + date_str, + "picked_up", + employee["extension"], + employee["name"], + shift_type=shift_type, + ) + try: + client.chat_postMessage( + channel=channel_id, + blocks=blocks, + text=f"Shift picked up for {date_str}", + ) + except Exception: + logger.exception("Failed to post pickup notification to channel") + _refresh_schedule_post(schedule, schedule_channel, client) + + +def _handle_drop( + respond, schedule, user_id, text, channel_id, client, schedule_channel +): + parts = text.split(maxsplit=1) + if len(parts) < 2: + respond(text="Usage: `/oncall drop ` (e.g. `/oncall drop friday`)") + return + + employee = schedule.get_employee_by_slack_id(user_id) + if not employee: + respond(text="You're not registered. Use `/oncall register ` first.") + return + + date = parse_date(parts[1]) + if not date: + respond( + text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05" + ) + return + + date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't drop a shift in the past.") + return + + day_name = date.strftime("%A") + found = _find_employee_shift(schedule, date_str, day_name, employee["extension"]) + + if not found: + ext, name, _source = schedule.resolve_shift(date_str, day_name) + respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).") + return + + ext, name, source, shift_type = found + schedule.mark_open(date_str, shift_type) + + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(FALLBACK_EXTENSION) + + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) + respond( + text=( + f"You dropped the shift for *{date_label}*{shift_label}. " + "It's now open for pickup." + ) + ) + + blocks = build_shift_change_message( + user_id, date_str, "dropped", ext, name, shift_type=shift_type + ) + try: + client.chat_postMessage( + channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}" + ) + except Exception: + logger.exception("Failed to post drop notification to channel") + _refresh_schedule_post(schedule, schedule_channel, client) + + +def _handle_swap( + respond, schedule, user_id, text, channel_id, client, schedule_channel +): + # Expected format: swap @user OR swap + parts = text.split(maxsplit=2) + if len(parts) < 3: + respond( + text="Usage: `/oncall swap @person` (e.g. `/oncall swap friday @sarah`)" + ) + return + + employee = schedule.get_employee_by_slack_id(user_id) + if not employee: + respond(text="You're not registered. Use `/oncall register ` first.") + return + + date = parse_date(parts[1]) + if not date: + respond(text=f"Couldn't parse date: `{parts[1]}`.") + return + + date_str = date.strftime("%Y-%m-%d") + if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): + respond(text="You can't swap a shift in the past.") + return + + day_name = date.strftime("%A") + found = _find_employee_shift(schedule, date_str, day_name, employee["extension"]) + + if not found: + ext, name, _source = schedule.resolve_shift(date_str, day_name) + respond( + text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts." + ) + return + + _ext, _name, _source, shift_type = found + + # Resolve target user — could be <@U12345> or an extension number + target_text = parts[2].strip() + slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text) + if slack_id_match: + target_slack_id = slack_id_match.group(1) + target = schedule.get_employee_by_slack_id(target_slack_id) + if not target: + respond( + text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register `." + ) + return + else: + target = schedule.get_employee_by_extension(target_text) + if not target: + respond(text=f"Extension `{target_text}` not found in the roster.") + return + + schedule.set_override(date_str, target["extension"], target["name"], shift_type) + + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(target["extension"]) + + blocks = build_shift_change_message( + target.get("slack_user_id", user_id), + date_str, + "swapped", + target["extension"], + target["name"], + shift_type=shift_type, + ) + date_label = date.strftime("%A, %b %-d") + shift_label = _shift_type_label(day_name, shift_type) + respond( + text=( + f"Swapped *{date_label}*{shift_label} to {target['name']} " + f"(Ext {target['extension']})." + ) + ) + + try: + client.chat_postMessage( + channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}" + ) + except Exception: + logger.exception("Failed to post swap notification to channel") + _refresh_schedule_post(schedule, schedule_channel, client) + + +def _handle_admin(respond, schedule, user_id, text, is_admin, client, schedule_channel): + if not is_admin: + respond(text="Admin commands are restricted. Contact an administrator.") + return + + parts = text.split() + if len(parts) < 2: + respond( + text=( + "*Admin Commands:*\n" + "`admin override [day|night]` — Assign shift\n" + "`admin open [day|night]` — Mark open\n" + "`admin clear [day|night]` — Remove override\n" + "`admin roster add ` — Add employee\n" + "`admin roster remove ` — Remove employee\n" + "`admin roster rename ` — Rename" + ) + ) + return + + subcmd = parts[1] + + if subcmd == "override": + if len(parts) < 4: + respond( + text="Usage: `/oncall admin override [day|night]`" + ) + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + ext = parts[3] + shift_type = ( + parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night" + ) + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + date_str = date.strftime("%Y-%m-%d") + schedule.set_override( + date_str, employee["extension"], employee["name"], shift_type + ) + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(employee["extension"]) + label = "Day" if shift_type == "day" else "Night" + respond( + text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})" + ) + _refresh_schedule_post(schedule, schedule_channel, client) + + elif subcmd == "open": + if len(parts) < 3: + respond(text="Usage: `/oncall admin open [day|night]`") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + shift_type = ( + parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" + ) + date_str = date.strftime("%Y-%m-%d") + schedule.mark_open(date_str, shift_type) + if is_today(date_str) and _is_active_shift_type(shift_type): + _update_3cx_routing(FALLBACK_EXTENSION) + label = "Day" if shift_type == "day" else "Night" + respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.") + _refresh_schedule_post(schedule, schedule_channel, client) + + elif subcmd == "clear": + if len(parts) < 3: + respond(text="Usage: `/oncall admin clear [day|night]`") + return + date = parse_date(parts[2]) + if not date: + respond(text=f"Couldn't parse date: `{parts[2]}`") + return + shift_type = ( + parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" + ) + date_str = date.strftime("%Y-%m-%d") + schedule.remove_override(date_str, shift_type) + if is_today(date_str) and _is_active_shift_type(shift_type): + day_name = date.strftime("%A") + ext, _name, _source = schedule.resolve_shift(date_str, day_name, shift_type) + _update_3cx_routing(ext) + label = "Day" if shift_type == "day" else "Night" + respond( + text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule." + ) + _refresh_schedule_post(schedule, schedule_channel, client) + + elif subcmd == "roster": + if len(parts) < 3: + respond(text="Usage: `admin roster add|remove|rename [name]`") + return + roster_cmd = parts[2] + + if roster_cmd == "add": + if len(parts) < 5: + respond(text="Usage: `/oncall admin roster add `") + return + ext = parts[3] + name = " ".join(parts[4:]) + added = schedule.add_roster_entry(ext, name) + if not added: + respond( + text=f"Extension `{ext}` already exists. Use `roster rename` to change the name." + ) + return + respond(text=f"Added *{name}* (Ext {ext}) to the roster.") + + elif roster_cmd == "remove": + if len(parts) < 4: + respond(text="Usage: `/oncall admin roster remove `") + return + ext = parts[3] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + schedule.remove_roster_entry(ext) + respond( + text=f"Removed *{employee.get('name', ext)}* (Ext {ext}) from the roster." + ) + + elif roster_cmd == "rename": + if len(parts) < 5: + respond(text="Usage: `/oncall admin roster rename `") + return + ext = parts[3] + employee = schedule.get_employee_by_extension(ext) + if not employee: + respond(text=f"Extension `{ext}` not found in the roster.") + return + new_name = " ".join(parts[4:]) + schedule.rename_roster_entry(ext, new_name) + respond( + text=f"Renamed Ext {ext}: {employee.get('name', '?')} → *{new_name}*" + ) + + else: + respond(text="Unknown roster command. Use `add`, `remove`, or `rename`.") + + else: + respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") + + +# ── App factory ───────────────────────────────────────────────────────── + + def create_app( bot_token: str, signing_secret: str, schedule_channel: str | None = None ) -> App: @@ -132,634 +774,14 @@ def create_app( ) schedule = ShiftSchedule() - # ── /oncall command ───────────────────────────────────────────────── - @app.command("/oncall") def handle_oncall(ack, command, respond, client): ack() - text = (command.get("text") or "").strip() - user_id = command["user_id"] - channel_id = command["channel_id"] - - is_admin = user_id in schedule.get_admin_users() - - if not text or text == "schedule": - _show_schedule(respond, schedule) - elif text == "next": - _show_next_week(respond, schedule) - elif text == "help": - respond(blocks=build_help_blocks(is_admin=is_admin)) - elif text == "roster": - respond(blocks=build_roster_blocks(schedule.get_roster())) - elif text == "pay": - _show_pay(respond, schedule) - elif text.startswith("rate"): - _handle_rate(respond, schedule, text) - elif text.startswith("register"): - _handle_register(respond, schedule, user_id, text) - elif text.startswith("pick"): - _handle_pick( - respond, schedule, user_id, text, schedule_channel or channel_id, client - ) - elif text.startswith("drop"): - _handle_drop( - respond, schedule, user_id, text, schedule_channel or channel_id, client - ) - elif text.startswith("swap"): - _handle_swap( - respond, schedule, user_id, text, schedule_channel or channel_id, client - ) - elif text.startswith("admin"): - _handle_admin(respond, schedule, user_id, text, is_admin, client) - else: - respond(text="Unknown command. Try `/oncall help`") - - # ── Interactive button: pick up open shift ────────────────────────── + dispatch_oncall(command, respond, client, schedule, schedule_channel) @app.action(re.compile(r"^pickup_")) def handle_pickup_button(ack, body, client, respond): ack() - action_id = body["actions"][0]["action_id"] - remainder = action_id.replace("pickup_", "") - if remainder.endswith("_day"): - date_str = remainder[:-4] - shift_type = "day" - else: - date_str = remainder - shift_type = "night" - - user_id = body["user"]["id"] - channel_id = body["channel"]["id"] - - today_str = datetime.now(EASTERN).strftime("%Y-%m-%d") - if date_str < today_str: - client.chat_postEphemeral( - channel=channel_id, - user=user_id, - text="That shift has already passed and can't be picked up.", - ) - return - - employee = schedule.get_employee_by_slack_id(user_id) - if not employee: - client.chat_postEphemeral( - channel=channel_id, - user=user_id, - text="You're not registered. Use `/oncall register ` first.", - ) - return - - claimed = schedule.claim_open_shift( - date_str, employee["extension"], employee["name"], shift_type - ) - if not claimed: - client.chat_postEphemeral( - channel=channel_id, - user=user_id, - text=f"That shift on *{date_str}* was already picked up by someone else.", - ) - return - - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(employee["extension"]) - - blocks = build_shift_change_message( - user_id, - date_str, - "picked_up", - employee["extension"], - employee["name"], - shift_type=shift_type, - ) - respond( - response_type="in_channel", - replace_original=False, - blocks=blocks, - text=f"Shift picked up for {date_str}", - ) - _refresh_schedule_post(client) - - def _refresh_schedule_post(client): - """Update the pinned schedule message in-place after a shift change.""" - channel = schedule_channel - if not channel: - return - post = schedule.get_schedule_post(channel) - if not post or not post.get("message_ts"): - return - try: - blocks = build_week_schedule(schedule) - now = datetime.now(EASTERN) - this_monday = now - timedelta(days=now.weekday()) - end_date = this_monday + timedelta(days=13) - client.chat_update( - channel=channel, - ts=post["message_ts"], - blocks=blocks, - text=f"After-Hours Schedule — {this_monday.strftime('%b %-d')} to {end_date.strftime('%b %-d')}", - ) - except Exception: - logger.warning("Could not update schedule post", exc_info=True) - - # ── Subcommand handlers ───────────────────────────────────────────── - - def _show_schedule(respond, schedule): - blocks = build_week_schedule(schedule) - respond(blocks=blocks) - - def _show_next_week(respond, schedule): - now = datetime.now(EASTERN) - # Jump 2 weeks ahead from this week's Monday - this_monday = now - timedelta(days=now.weekday()) - next_start = this_monday + timedelta(days=14) - blocks = build_week_schedule(schedule, start_date=next_start) - respond(blocks=blocks) - - def _show_pay(respond, schedule): - now = datetime.now(EASTERN) - # Show last completed week's pay (previous Monday–Sunday) - this_monday = now - timedelta(days=now.weekday()) - prev_monday = this_monday - timedelta(days=7) - week_key = prev_monday.strftime("%Y-%m-%d") - - pay_record = schedule.get_pay_record(week_key) - if pay_record and pay_record.get("breakdown"): - prev_sunday = prev_monday + timedelta(days=6) - week_label = ( - f"{prev_monday.strftime('%b %-d')} to {prev_sunday.strftime('%b %-d')}" - ) - blocks = build_pay_summary_blocks( - week_label, pay_record["breakdown"], pay_record["totals"] - ) - respond(blocks=blocks) - else: - respond( - text=f"No pay record found for the week of {prev_monday.strftime('%b %-d')}." - ) - - def _handle_rate(respond, schedule, text): - parts = text.split() - # /oncall rate — show current rates - if len(parts) == 1: - default_rate = schedule.get_shift_rate() - roster = schedule.get_roster() - lines = [f"*Default rate:* ${default_rate:.2f}/shift\n"] - custom = [ - (e["SK"], e.get("name", "Unknown"), float(e["shift_rate"])) - for e in roster - if e.get("shift_rate") - ] - if custom: - lines.append("*Per-person rates:*") - for ext, name, rate in sorted(custom, key=lambda x: x[0]): - lines.append(f"• {name} (Ext {ext}) — ${rate:.2f}/shift") - else: - lines.append("_No per-person rates set — everyone uses the default._") - respond(text="\n".join(lines)) - return - - # /oncall rate default - if parts[1] == "default": - if len(parts) < 3: - respond( - text="Usage: `/oncall rate default ` (e.g. `/oncall rate default 50`)" - ) - return - try: - amount = float(parts[2].replace("$", "")) - except ValueError: - respond(text=f"Invalid amount: `{parts[2]}`") - return - schedule.set_default_shift_rate(amount) - respond(text=f"Default shift rate set to *${amount:.2f}*.") - return - - # /oncall rate - if len(parts) < 3: - respond( - text="Usage: `/oncall rate ` (e.g. `/oncall rate 114 75`)" - ) - return - - ext = parts[1] - employee = schedule.get_employee_by_extension(ext) - if not employee: - respond(text=f"Extension `{ext}` not found in the roster.") - return - - try: - amount = float(parts[2].replace("$", "")) - except ValueError: - respond(text=f"Invalid amount: `{parts[2]}`") - return - - schedule.set_employee_shift_rate(ext, amount) - respond( - text=f"Shift rate for *{employee['name']}* (Ext {ext}) set to *${amount:.2f}*." - ) - - def _handle_register(respond, schedule, user_id, text): - parts = text.split() - if len(parts) < 2: - respond( - text="Usage: `/oncall register ` (e.g. `/oncall register 114`)" - ) - return - - ext = parts[1].strip() - employee = schedule.register_user(user_id, ext) - if not employee: - respond( - text=f"Extension {ext} not found in the roster. Check `/oncall roster`." - ) - return - - respond(text=f"Linked your account to *{employee['name']}* (Ext {ext}).") - - def _handle_pick(respond, schedule, user_id, text, channel_id, client): - parts = text.split() - if len(parts) < 2: - respond( - text="Usage: `/oncall pick [day|night]` (e.g. `/oncall pick friday`)" - ) - return - - employee = schedule.get_employee_by_slack_id(user_id) - if not employee: - respond( - text="You're not registered. Use `/oncall register ` first." - ) - return - - explicit_shift = ( - parts[2] if len(parts) > 2 and parts[2] in ("day", "night") else None - ) - date_text = parts[1] - date = parse_date(date_text) - if not date: - respond( - text=f"Couldn't parse date: `{date_text}`. Try: today, tomorrow, friday, 4/5, 2026-04-05" - ) - return - - date_str = date.strftime("%Y-%m-%d") - if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): - respond(text="You can't pick up a shift in the past.") - return - - day_name = date.strftime("%A") - - if explicit_shift: - shift_type = explicit_shift - ext, name, source = schedule.resolve_shift(date_str, day_name, shift_type) - elif day_name in WEEKEND_DAYS: - shift_type = "night" - for st in ("day", "night"): - ext, name, source = schedule.resolve_shift(date_str, day_name, st) - if source == "available": - shift_type = st - break - else: - ext, name, source = schedule.resolve_shift(date_str, day_name) - else: - shift_type = "night" - ext, name, source = schedule.resolve_shift(date_str, day_name) - - # Already assigned to someone else (not open) - if source in ("weekly", "override") and ext != FALLBACK_EXTENSION: - respond( - text=f"That shift is already covered by {name} (Ext {ext}). They'd need to drop it first." - ) - return - - schedule.set_override( - date_str, employee["extension"], employee["name"], shift_type - ) - - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(employee["extension"]) - - date_label = date.strftime("%A, %b %-d") - shift_label = _shift_type_label(day_name, shift_type) - respond(text=f"You picked up the shift for *{date_label}*{shift_label}.") - - blocks = build_shift_change_message( - user_id, - date_str, - "picked_up", - employee["extension"], - employee["name"], - shift_type=shift_type, - ) - try: - client.chat_postMessage( - channel=channel_id, - blocks=blocks, - text=f"Shift picked up for {date_str}", - ) - except Exception: - logger.exception("Failed to post pickup notification to channel") - _refresh_schedule_post(client) - - def _handle_drop(respond, schedule, user_id, text, channel_id, client): - parts = text.split(maxsplit=1) - if len(parts) < 2: - respond(text="Usage: `/oncall drop ` (e.g. `/oncall drop friday`)") - return - - employee = schedule.get_employee_by_slack_id(user_id) - if not employee: - respond( - text="You're not registered. Use `/oncall register ` first." - ) - return - - date = parse_date(parts[1]) - if not date: - respond( - text=f"Couldn't parse date: `{parts[1]}`. Try: today, tomorrow, friday, 4/5, 2026-04-05" - ) - return - - date_str = date.strftime("%Y-%m-%d") - if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): - respond(text="You can't drop a shift in the past.") - return - - day_name = date.strftime("%A") - found = _find_employee_shift( - schedule, date_str, day_name, employee["extension"] - ) - - if not found: - ext, name, _source = schedule.resolve_shift(date_str, day_name) - respond(text=f"That's not your shift — it belongs to {name} (Ext {ext}).") - return - - ext, name, source, shift_type = found - schedule.mark_open(date_str, shift_type) - - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(FALLBACK_EXTENSION) - - date_label = date.strftime("%A, %b %-d") - shift_label = _shift_type_label(day_name, shift_type) - respond( - text=( - f"You dropped the shift for *{date_label}*{shift_label}. " - "It's now open for pickup." - ) - ) - - blocks = build_shift_change_message( - user_id, date_str, "dropped", ext, name, shift_type=shift_type - ) - try: - client.chat_postMessage( - channel=channel_id, blocks=blocks, text=f"Shift dropped for {date_str}" - ) - except Exception: - logger.exception("Failed to post drop notification to channel") - _refresh_schedule_post(client) - - def _handle_swap(respond, schedule, user_id, text, channel_id, client): - # Expected format: swap @user OR swap - parts = text.split(maxsplit=2) - if len(parts) < 3: - respond( - text="Usage: `/oncall swap @person` (e.g. `/oncall swap friday @sarah`)" - ) - return - - employee = schedule.get_employee_by_slack_id(user_id) - if not employee: - respond( - text="You're not registered. Use `/oncall register ` first." - ) - return - - date = parse_date(parts[1]) - if not date: - respond(text=f"Couldn't parse date: `{parts[1]}`.") - return - - date_str = date.strftime("%Y-%m-%d") - if date_str < datetime.now(EASTERN).strftime("%Y-%m-%d"): - respond(text="You can't swap a shift in the past.") - return - - day_name = date.strftime("%A") - found = _find_employee_shift( - schedule, date_str, day_name, employee["extension"] - ) - - if not found: - ext, name, _source = schedule.resolve_shift(date_str, day_name) - respond( - text=f"That's not your shift — it belongs to {name} (Ext {ext}). You can only swap your own shifts." - ) - return - - _ext, _name, _source, shift_type = found - - # Resolve target user — could be <@U12345> or an extension number - target_text = parts[2].strip() - slack_id_match = re.match(r"<@(\w+)(?:\|[^>]*)?>", target_text) - if slack_id_match: - target_slack_id = slack_id_match.group(1) - target = schedule.get_employee_by_slack_id(target_slack_id) - if not target: - respond( - text=f"<@{target_slack_id}> isn't registered. They need to run `/oncall register `." - ) - return - else: - target = schedule.get_employee_by_extension(target_text) - if not target: - respond(text=f"Extension `{target_text}` not found in the roster.") - return - - schedule.set_override(date_str, target["extension"], target["name"], shift_type) - - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(target["extension"]) - - blocks = build_shift_change_message( - target.get("slack_user_id", user_id), - date_str, - "swapped", - target["extension"], - target["name"], - shift_type=shift_type, - ) - date_label = date.strftime("%A, %b %-d") - shift_label = _shift_type_label(day_name, shift_type) - respond( - text=( - f"Swapped *{date_label}*{shift_label} to {target['name']} " - f"(Ext {target['extension']})." - ) - ) - - try: - client.chat_postMessage( - channel=channel_id, blocks=blocks, text=f"Shift swapped for {date_str}" - ) - except Exception: - logger.exception("Failed to post swap notification to channel") - _refresh_schedule_post(client) - - def _handle_admin(respond, schedule, user_id, text, is_admin, client): - if not is_admin: - respond(text="Admin commands are restricted. Contact an administrator.") - return - - parts = text.split() - if len(parts) < 2: - respond( - text=( - "*Admin Commands:*\n" - "`admin override [day|night]` — Assign shift\n" - "`admin open [day|night]` — Mark open\n" - "`admin clear [day|night]` — Remove override\n" - "`admin roster add ` — Add employee\n" - "`admin roster remove ` — Remove employee\n" - "`admin roster rename ` — Rename" - ) - ) - return - - subcmd = parts[1] - - if subcmd == "override": - if len(parts) < 4: - respond( - text="Usage: `/oncall admin override [day|night]`" - ) - return - date = parse_date(parts[2]) - if not date: - respond(text=f"Couldn't parse date: `{parts[2]}`") - return - ext = parts[3] - shift_type = ( - parts[4] if len(parts) > 4 and parts[4] in ("day", "night") else "night" - ) - employee = schedule.get_employee_by_extension(ext) - if not employee: - respond(text=f"Extension `{ext}` not found in the roster.") - return - date_str = date.strftime("%Y-%m-%d") - schedule.set_override( - date_str, employee["extension"], employee["name"], shift_type - ) - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(employee["extension"]) - label = "Day" if shift_type == "day" else "Night" - respond( - text=f"Override set: *{date.strftime('%A, %b %-d')}* ({label}) → {employee['name']} (Ext {ext})" - ) - _refresh_schedule_post(client) - - elif subcmd == "open": - if len(parts) < 3: - respond(text="Usage: `/oncall admin open [day|night]`") - return - date = parse_date(parts[2]) - if not date: - respond(text=f"Couldn't parse date: `{parts[2]}`") - return - shift_type = ( - parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" - ) - date_str = date.strftime("%Y-%m-%d") - schedule.mark_open(date_str, shift_type) - if is_today(date_str) and _is_active_shift_type(shift_type): - _update_3cx_routing(FALLBACK_EXTENSION) - label = "Day" if shift_type == "day" else "Night" - respond(text=f"*{date.strftime('%A, %b %-d')}* ({label}) marked as open.") - _refresh_schedule_post(client) - - elif subcmd == "clear": - if len(parts) < 3: - respond(text="Usage: `/oncall admin clear [day|night]`") - return - date = parse_date(parts[2]) - if not date: - respond(text=f"Couldn't parse date: `{parts[2]}`") - return - shift_type = ( - parts[3] if len(parts) > 3 and parts[3] in ("day", "night") else "night" - ) - date_str = date.strftime("%Y-%m-%d") - schedule.remove_override(date_str, shift_type) - if is_today(date_str) and _is_active_shift_type(shift_type): - day_name = date.strftime("%A") - ext, _name, _source = schedule.resolve_shift( - date_str, day_name, shift_type - ) - _update_3cx_routing(ext) - label = "Day" if shift_type == "day" else "Night" - respond( - text=f"Override cleared for *{date.strftime('%A, %b %-d')}* ({label}) — reverted to weekly schedule." - ) - _refresh_schedule_post(client) - - elif subcmd == "roster": - if len(parts) < 3: - respond(text="Usage: `admin roster add|remove|rename [name]`") - return - roster_cmd = parts[2] - - if roster_cmd == "add": - if len(parts) < 5: - respond(text="Usage: `/oncall admin roster add `") - return - ext = parts[3] - name = " ".join(parts[4:]) - added = schedule.add_roster_entry(ext, name) - if not added: - respond( - text=f"Extension `{ext}` already exists. Use `roster rename` to change the name." - ) - return - respond(text=f"Added *{name}* (Ext {ext}) to the roster.") - - elif roster_cmd == "remove": - if len(parts) < 4: - respond(text="Usage: `/oncall admin roster remove `") - return - ext = parts[3] - employee = schedule.get_employee_by_extension(ext) - if not employee: - respond(text=f"Extension `{ext}` not found in the roster.") - return - schedule.remove_roster_entry(ext) - respond( - text=f"Removed *{employee.get('name', ext)}* (Ext {ext}) from the roster." - ) - - elif roster_cmd == "rename": - if len(parts) < 5: - respond(text="Usage: `/oncall admin roster rename `") - return - ext = parts[3] - employee = schedule.get_employee_by_extension(ext) - if not employee: - respond(text=f"Extension `{ext}` not found in the roster.") - return - new_name = " ".join(parts[4:]) - schedule.rename_roster_entry(ext, new_name) - respond( - text=f"Renamed Ext {ext}: {employee.get('name', '?')} → *{new_name}*" - ) - - else: - respond( - text="Unknown roster command. Use `add`, `remove`, or `rename`." - ) - - else: - respond(text=f"Unknown admin command: `{subcmd}`. Try `/oncall help`.") + handle_pickup(body, respond, client, schedule, schedule_channel) return app diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0417ca9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +"""Shared pytest fixtures: AWS env, a moto-backed DynamoDB table, a ShiftSchedule +bound to it, and a seeding helper for the single-table data model.""" + +import boto3 +import pytest +from moto import mock_aws + +TABLE_NAME = "afterhours-shifts" + + +@pytest.fixture(autouse=True) +def aws_env(monkeypatch): + """Fake AWS credentials + the env vars the handlers read. + + Autouse so no test can accidentally reach real AWS. 3CX env vars are left + unset by default, so the slack-bot's _update_3cx_routing short-circuits + unless a test sets them explicitly. + """ + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SECURITY_TOKEN", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1") + monkeypatch.setenv("SHIFT_TABLE", TABLE_NAME) + monkeypatch.setenv("SHIFT_CHANNEL", "C_TEST") + monkeypatch.delenv("QUEUE_NUMBER", raising=False) + monkeypatch.delenv("TCX_SECRET_PREFIX", raising=False) + + +def _create_table(dynamodb): + table = dynamodb.create_table( + TableName=TABLE_NAME, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + table.wait_until_exists() + return table + + +@pytest.fixture +def dynamodb_table(aws_env): + """A mocked `afterhours-shifts` table (PK/SK, PAY_PER_REQUEST). The moto + context stays open for the duration of the test.""" + with mock_aws(): + dynamodb = boto3.resource("dynamodb", region_name="us-east-1") + yield _create_table(dynamodb) + + +@pytest.fixture +def schedule(dynamodb_table): + """A ShiftSchedule bound to the mocked table.""" + from shared.schedule import ShiftSchedule + + return ShiftSchedule() + + +class Seeder: + """Thin helper to put single-table items matching schedule.py's key model.""" + + def __init__(self, table): + self.table = table + + def roster(self, ext, name, slack_user_id="", **extra): + self.table.put_item( + Item={ + "PK": "ROSTER", + "SK": ext, + "name": name, + "extension": ext, + "slack_user_id": slack_user_id, + **extra, + } + ) + + def weekly(self, day_name, ext, name, shift_type="night"): + sk = f"{day_name}-Day" if shift_type == "day" else day_name + self.table.put_item( + Item={"PK": "WEEKLY", "SK": sk, "extension": ext, "name": name} + ) + + def override(self, date_str, ext, name, shift_type="night"): + sk = f"{date_str}-DAY" if shift_type == "day" else date_str + self.table.put_item( + Item={"PK": "OVERRIDE", "SK": sk, "extension": ext, "name": name} + ) + + def open_shift(self, date_str, shift_type="night"): + sk = f"{date_str}-DAY" if shift_type == "day" else date_str + self.table.put_item( + Item={"PK": "OVERRIDE", "SK": sk, "extension": "OPEN", "name": "Open"} + ) + + def config(self, **kwargs): + self.table.put_item(Item={"PK": "CONFIG", "SK": "CONFIG", **kwargs}) + + def schedule_post(self, channel, message_ts, week_start="2026-06-01"): + self.table.put_item( + Item={ + "PK": "SCHEDULE_POST", + "SK": channel, + "message_ts": message_ts, + "week_start": week_start, + } + ) + + +@pytest.fixture +def seed(dynamodb_table): + return Seeder(dynamodb_table) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..d8bc2d5 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,7 @@ +# Test-only dependencies. The CI reusable workflow (ci-python-sam.yaml) installs +# every requirements.txt it finds when run-tests is true, so this file is picked +# up automatically alongside each Lambda's runtime requirements. +pytest>=8.0 +moto[dynamodb,ses,secretsmanager]>=5.0 +responses>=0.25 +freezegun>=1.5 diff --git a/tests/ring_scheduler/conftest.py b/tests/ring_scheduler/conftest.py new file mode 100644 index 0000000..2ef4a43 --- /dev/null +++ b/tests/ring_scheduler/conftest.py @@ -0,0 +1,22 @@ +"""Load src/ring-scheduler/app.py under a unique module name.""" + +import importlib.util +import pathlib +import sys + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def ringscheduler_app(): + return _load("ringscheduler_app", "src/ring-scheduler/app.py") diff --git a/tests/ring_scheduler/test_handler.py b/tests/ring_scheduler/test_handler.py new file mode 100644 index 0000000..4e4e479 --- /dev/null +++ b/tests/ring_scheduler/test_handler.py @@ -0,0 +1,61 @@ +"""Tests for the ring-scheduler Lambda handler.""" + +from unittest.mock import MagicMock + +import pytest +from freezegun import freeze_time + +from shared.schedule import FALLBACK_EXTENSION + +# Monday 2026-06-01 08:00 ET — weekday valid_hours is {8}, so the guard passes +# without force; active shift is night. +MON_0800 = "2026-06-01 12:00:00" + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setenv("QUEUE_NUMBER", "800") + monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-") + + +@pytest.fixture +def routing(ringscheduler_app, monkeypatch): + spy = MagicMock(name="update_queue_routing", return_value={"queue": "800"}) + monkeypatch.setattr(ringscheduler_app, "update_queue_routing", spy) + monkeypatch.setattr(ringscheduler_app, "get_secret", lambda _id: "secret") + return spy + + +@freeze_time(MON_0800) +def test_override_extension_bypasses_schedule(ringscheduler_app, env, routing): + result = ringscheduler_app.handler({"override_extension": "199"}, None) + assert routing.call_args.kwargs["extension"] == "199" + assert result["shift_type"] == "night" + + +@freeze_time(MON_0800) +def test_resolves_assigned_extension(ringscheduler_app, schedule, seed, env, routing): + seed.weekly("Monday", "114", "Alice") + ringscheduler_app.handler({}, None) + assert routing.call_args.kwargs["extension"] == "114" + assert routing.call_args.kwargs["queue_number"] == "800" + + +@freeze_time(MON_0800) +def test_unassigned_uses_fallback(ringscheduler_app, schedule, seed, env, routing): + ringscheduler_app.handler({}, None) + assert routing.call_args.kwargs["extension"] == FALLBACK_EXTENSION + + +@freeze_time(MON_0800) +def test_routing_failure_returns_error(ringscheduler_app, schedule, env, routing): + routing.side_effect = RuntimeError("3CX down") + result = ringscheduler_app.handler({"override_extension": "199"}, None) + assert result["error"] is True + + +@freeze_time("2026-06-01 13:00:00") # 09:00 ET — not a valid scheduler hour +def test_skips_on_wrong_hour(ringscheduler_app, env, routing): + result = ringscheduler_app.handler({}, None) + assert result == {"skipped": True} + routing.assert_not_called() diff --git a/tests/roster_sync/conftest.py b/tests/roster_sync/conftest.py new file mode 100644 index 0000000..6d24004 --- /dev/null +++ b/tests/roster_sync/conftest.py @@ -0,0 +1,22 @@ +"""Load src/roster-sync/app.py under a unique module name.""" + +import importlib.util +import pathlib +import sys + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def rostersync_app(): + return _load("rostersync_app", "src/roster-sync/app.py") diff --git a/tests/roster_sync/test_handler.py b/tests/roster_sync/test_handler.py new file mode 100644 index 0000000..443368c --- /dev/null +++ b/tests/roster_sync/test_handler.py @@ -0,0 +1,91 @@ +"""Tests for the roster-sync Lambda handler.""" + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-") + + +@pytest.fixture +def fake_3cx(rostersync_app, monkeypatch): + """Patch ThreeCXClient + get_secret. Returns a setter for group members.""" + client = MagicMock(name="ThreeCXClient") + monkeypatch.setattr(rostersync_app, "ThreeCXClient", MagicMock(return_value=client)) + monkeypatch.setattr(rostersync_app, "get_secret", lambda _id: "secret") + + def set_members(members): + client.get_group_members.return_value = members + + return set_members + + +def test_adds_removes_and_preserves_links( + rostersync_app, schedule, seed, env, fake_3cx +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster("115", "Bob", slack_user_id="U_BOB") # departs + fake_3cx( + [ + {"Number": "114", "MemberName": "Alice", "Type": "Extension"}, + {"Number": "116", "MemberName": "New Person", "Type": "Extension"}, + { + "Number": "117", + "MemberName": "Voicemail", + "Type": "Extension", + }, # excluded + { + "Number": "118", + "MemberName": "Reception", + "Type": "RingGroup", + }, # excluded + ] + ) + + result = rostersync_app.handler({"force": True}, None) + + # 116 added, 115 removed, 117/118 ignored. + assert any("116" in a for a in result["added"]) + assert any("115" in r for r in result["removed"]) + assert schedule.get_employee_by_extension("116")["name"] == "New Person" + assert schedule.get_employee_by_extension("115") is None + assert schedule.get_employee_by_extension("117") is None + # Existing link preserved. + assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE" + + +def test_renames_changed_member_preserving_link( + rostersync_app, schedule, seed, env, fake_3cx +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + fake_3cx([{"Number": "114", "MemberName": "Alicia", "Type": "Extension"}]) + + result = rostersync_app.handler({"force": True}, None) + + assert any("114" in u for u in result["updated"]) + emp = schedule.get_employee_by_extension("114") + assert emp["name"] == "Alicia" + assert emp["slack_user_id"] == "U_ALICE" + + +def test_empty_3cx_result_does_not_wipe_roster( + rostersync_app, schedule, seed, env, fake_3cx +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + fake_3cx([]) + + result = rostersync_app.handler({"force": True}, None) + + assert result["removed"] == [] + assert schedule.get_employee_by_extension("114") is not None + + +def test_skips_when_wrong_hour_and_not_forced(rostersync_app, env, fake_3cx): + import freezegun + + # 12:00 UTC = 08:00 ET, not 6am → skip. + with freezegun.freeze_time("2026-06-01 12:00:00"): + assert rostersync_app.handler({}, None) == {"skipped": True} diff --git a/tests/shared/test_blocks.py b/tests/shared/test_blocks.py new file mode 100644 index 0000000..0deb1d4 --- /dev/null +++ b/tests/shared/test_blocks.py @@ -0,0 +1,120 @@ +"""Tests for shared.blocks — Block Kit builders.""" + +from freezegun import freeze_time + +from shared.blocks import ( + build_help_blocks, + build_pay_summary_blocks, + build_roster_blocks, + build_shift_change_message, + build_week_schedule, +) + + +def _all_action_ids(blocks): + ids = [] + for b in blocks: + if b.get("type") == "actions": + ids.extend(e["action_id"] for e in b["elements"]) + return ids + + +class TestBuildWeekSchedule: + @freeze_time("2026-06-01 12:00:00") # Monday + def test_header_and_section_present(self, schedule): + blocks = build_week_schedule(schedule) + assert blocks[0]["type"] == "header" + assert "After-Hours Schedule" in blocks[0]["text"]["text"] + assert blocks[1]["type"] == "section" + + @freeze_time("2026-06-01 12:00:00") + def test_all_available_produces_pickup_buttons(self, schedule): + # Empty schedule → every shift is available → pickup buttons exist, + # including a weekend day button with the _day suffix. + blocks = build_week_schedule(schedule) + action_ids = _all_action_ids(blocks) + assert "pickup_2026-06-03" in action_ids # Wednesday night + assert "pickup_2026-06-06_day" in action_ids # Saturday day shift + assert "pickup_2026-06-06" in action_ids # Saturday night shift + + @freeze_time("2026-06-01 12:00:00") + def test_assigned_shift_has_no_pickup_button(self, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + blocks = build_week_schedule(schedule) + assert "pickup_2026-06-03" not in _all_action_ids(blocks) + assert "Alice (Ext 114)" in blocks[1]["text"]["text"] + + +class TestBuildShiftChangeMessage: + def test_picked_up_weekday(self): + blocks = build_shift_change_message( + "U1", "2026-06-03", "picked_up", "114", "Alice" + ) + text = blocks[0]["text"]["text"] + assert "<@U1>" in text and "picked up" in text and "Ext 114" in text + # Weekday → no (Day/Night) label + assert "(Night" not in text + + def test_dropped_shows_available(self): + blocks = build_shift_change_message( + "U1", "2026-06-03", "dropped", "114", "Alice" + ) + assert "Available" in blocks[0]["text"]["text"] + + def test_swapped_text(self): + blocks = build_shift_change_message("U2", "2026-06-03", "swapped", "115", "Bob") + assert "swapped" in blocks[0]["text"]["text"] + + def test_weekend_includes_shift_label(self): + blocks = build_shift_change_message( + "U1", "2026-06-06", "picked_up", "200", "Alice", shift_type="day" + ) + assert "Day (8am" in blocks[0]["text"]["text"] + + +class TestBuildPaySummaryBlocks: + def test_renders_breakdown_and_totals(self): + breakdown = [ + { + "day": "Mon", + "date_label": "Jun 1", + "name": "Alice", + "extension": "114", + "rate": 50.0, + } + ] + totals = { + "Alice": {"shifts": 1, "total": 50.0, "extension": "114", "rate": 50.0} + } + blocks = build_pay_summary_blocks("Jun 1 to Jun 7", breakdown, totals) + assert blocks[0]["type"] == "header" + assert "Jun 1 to Jun 7" in blocks[0]["text"]["text"] + text = blocks[1]["text"]["text"] + assert "Alice" in text and "$50.00" in text and "1 shift" in text + + +class TestBuildHelpBlocks: + def test_non_admin_excludes_admin_section(self): + text = build_help_blocks(is_admin=False)[0]["text"]["text"] + assert "Admin Commands" not in text + + def test_admin_includes_admin_section(self): + text = build_help_blocks(is_admin=True)[0]["text"]["text"] + assert "Admin Commands" in text + + +class TestBuildRosterBlocks: + def test_empty_roster(self): + text = build_roster_blocks([])[0]["text"]["text"] + assert "No employees" in text + + def test_linked_and_unlinked(self): + roster = [ + {"SK": "115", "name": "Bob", "slack_user_id": ""}, + {"SK": "114", "name": "Alice", "slack_user_id": "U_ALICE"}, + ] + text = build_roster_blocks(roster)[0]["text"]["text"] + # Sorted by extension → Alice (114) appears before Bob (115) + assert text.index("Alice") < text.index("Bob") + assert "<@U_ALICE>" in text + assert "_not linked_" in text diff --git a/tests/shared/test_ring_scheduler.py b/tests/shared/test_ring_scheduler.py new file mode 100644 index 0000000..55ae19e --- /dev/null +++ b/tests/shared/test_ring_scheduler.py @@ -0,0 +1,43 @@ +"""Tests for shared.ring_scheduler.update_queue_routing (ThreeCXClient faked).""" + +import shared.ring_scheduler as ring_scheduler + + +class FakeThreeCXClient: + instances = [] + + def __init__(self, domain, auth_mode, client_id, client_secret): + self.domain = domain + self.auth_mode = auth_mode + self.client_id = client_id + self.client_secret = client_secret + self.forwarding = None + FakeThreeCXClient.instances.append(self) + + def get_queue(self, number): + self.queue_number = number + return {"Id": 42, "Number": number} + + def update_queue_forwarding(self, queue_id, closed, holiday): + self.forwarding = {"queue_id": queue_id, "closed": closed, "holiday": holiday} + return 200 + + +def test_update_queue_routing_points_queue_at_extension(monkeypatch): + FakeThreeCXClient.instances = [] + monkeypatch.setattr(ring_scheduler, "ThreeCXClient", FakeThreeCXClient) + + result = ring_scheduler.update_queue_routing( + extension="114", + queue_number="800", + domain="test.3cx.us", + client_id="cid", + client_secret="secret", + ) + + assert result == {"extension": "114", "queue": "800"} + client = FakeThreeCXClient.instances[0] + assert client.auth_mode == "oauth" + assert client.queue_number == "800" + # Both closed and holiday routes point at the on-call extension. + assert client.forwarding == {"queue_id": 42, "closed": "114", "holiday": "114"} diff --git a/tests/shared/test_schedule.py b/tests/shared/test_schedule.py new file mode 100644 index 0000000..771ec7b --- /dev/null +++ b/tests/shared/test_schedule.py @@ -0,0 +1,220 @@ +"""Tests for shared.schedule — the DynamoDB single-table data model.""" + +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule, determine_shift_type + +ET = ZoneInfo("America/New_York") + +# 2026-06-01 is a Monday, so 06-06 is Saturday and 06-07 is Sunday. +SAT = "2026-06-06" +SUN = "2026-06-07" +WED = "2026-06-03" + + +class TestDetermineShiftType: + @pytest.mark.parametrize( + "dt,expected", + [ + (datetime(2026, 6, 6, 8, 0, tzinfo=ET), "day"), # Sat 08:00 boundary + (datetime(2026, 6, 6, 10, 0, tzinfo=ET), "day"), # Sat mid-day + (datetime(2026, 6, 6, 16, 59, tzinfo=ET), "day"), # Sat just before 17 + (datetime(2026, 6, 6, 17, 0, tzinfo=ET), "night"), # Sat 17:00 boundary + (datetime(2026, 6, 6, 7, 59, tzinfo=ET), "night"), # Sat before 08 + (datetime(2026, 6, 7, 12, 0, tzinfo=ET), "day"), # Sun mid-day + (datetime(2026, 6, 3, 12, 0, tzinfo=ET), "night"), # Wed — always night + (datetime(2026, 6, 3, 9, 0, tzinfo=ET), "night"), # weekday morning + ], + ) + def test_boundaries(self, dt, expected): + assert determine_shift_type(dt) == expected + + def test_defaults_to_now_when_none(self): + # Should not raise and returns a valid shift type. + assert determine_shift_type() in ("day", "night") + + +class TestResolveShift: + def test_unassigned_is_available(self, schedule): + assert schedule.resolve_shift(WED, "Wednesday") == ( + FALLBACK_EXTENSION, + "Available", + "available", + ) + + def test_weekly_assignment(self, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + assert schedule.resolve_shift(WED, "Wednesday") == ("114", "Alice", "weekly") + + def test_weekly_fallback_extension_is_available(self, schedule, seed): + seed.weekly("Wednesday", FALLBACK_EXTENSION, "Fallback") + ext, name, source = schedule.resolve_shift(WED, "Wednesday") + assert (ext, source) == (FALLBACK_EXTENSION, "available") + + def test_override_takes_precedence_over_weekly(self, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + seed.override(WED, "115", "Bob") + assert schedule.resolve_shift(WED, "Wednesday") == ("115", "Bob", "override") + + def test_open_override_is_available(self, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + seed.open_shift(WED) + ext, name, source = schedule.resolve_shift(WED, "Wednesday") + assert (ext, name, source) == (FALLBACK_EXTENSION, "Available", "available") + + def test_weekend_day_shift_uses_day_key(self, schedule, seed): + seed.weekly("Saturday", "200", "DayPerson", shift_type="day") + seed.weekly("Saturday", "201", "NightPerson", shift_type="night") + assert schedule.resolve_shift(SAT, "Saturday", "day") == ( + "200", + "DayPerson", + "weekly", + ) + assert schedule.resolve_shift(SAT, "Saturday", "night") == ( + "201", + "NightPerson", + "weekly", + ) + + def test_day_and_night_overrides_are_independent(self, schedule, seed): + seed.override(SAT, "200", "DayPerson", shift_type="day") + # Night shift has no override → available + assert schedule.resolve_shift(SAT, "Saturday", "day")[0] == "200" + assert schedule.resolve_shift(SAT, "Saturday", "night")[2] == "available" + + +class TestOverrides: + def test_set_and_get_override_night(self, schedule): + schedule.set_override(WED, "114", "Alice") + item = schedule.get_override(WED) + assert item["extension"] == "114" and item["name"] == "Alice" + + def test_set_and_get_override_day_uses_separate_sk(self, schedule): + schedule.set_override(SAT, "200", "DayPerson", shift_type="day") + assert schedule.get_override(SAT, "day")["extension"] == "200" + # The night SK must be untouched + assert schedule.get_override(SAT, "night") is None + + def test_mark_open(self, schedule): + schedule.set_override(WED, "114", "Alice") + schedule.mark_open(WED) + assert schedule.get_override(WED)["extension"] == "OPEN" + + def test_remove_override_reverts_to_weekly(self, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + schedule.set_override(WED, "115", "Bob") + schedule.remove_override(WED) + assert schedule.resolve_shift(WED, "Wednesday") == ("114", "Alice", "weekly") + + +class TestClaimOpenShift: + def test_claim_when_no_override_succeeds(self, schedule): + assert schedule.claim_open_shift(WED, "114", "Alice") is True + assert schedule.get_override(WED)["extension"] == "114" + + def test_claim_when_open_succeeds(self, schedule): + schedule.mark_open(WED) + assert schedule.claim_open_shift(WED, "114", "Alice") is True + assert schedule.get_override(WED)["extension"] == "114" + + def test_claim_when_already_assigned_fails_and_does_not_overwrite(self, schedule): + schedule.set_override(WED, "115", "Bob") + assert schedule.claim_open_shift(WED, "114", "Alice") is False + assert schedule.get_override(WED)["extension"] == "115" + + +class TestRoster: + def test_get_roster_and_by_extension(self, schedule, seed): + seed.roster("114", "Alice") + seed.roster("115", "Bob") + assert {e["SK"] for e in schedule.get_roster()} == {"114", "115"} + assert schedule.get_employee_by_extension("114")["name"] == "Alice" + assert schedule.get_employee_by_extension("999") is None + + def test_get_employee_by_slack_id(self, schedule, seed): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster("115", "Bob") + assert schedule.get_employee_by_slack_id("U_ALICE")["SK"] == "114" + assert schedule.get_employee_by_slack_id("U_NOBODY") is None + + def test_register_user_links_slack_id(self, schedule, seed): + seed.roster("114", "Alice") + result = schedule.register_user("U_ALICE", "114") + assert result["slack_user_id"] == "U_ALICE" + assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE" + + def test_register_user_unknown_extension_returns_none(self, schedule): + assert schedule.register_user("U_ALICE", "999") is None + + def test_add_roster_entry_new_and_duplicate(self, schedule): + assert schedule.add_roster_entry("114", "Alice") is True + assert schedule.add_roster_entry("114", "Alice Again") is False + assert schedule.get_employee_by_extension("114")["name"] == "Alice" + + def test_remove_roster_entry(self, schedule, seed): + seed.roster("114", "Alice") + schedule.remove_roster_entry("114") + assert schedule.get_employee_by_extension("114") is None + + def test_rename_roster_entry(self, schedule, seed): + seed.roster("114", "Alice") + schedule.rename_roster_entry("114", "Alicia") + assert schedule.get_employee_by_extension("114")["name"] == "Alicia" + + +class TestRates: + def test_default_rate_from_config(self, schedule, seed): + seed.config(shift_rate="50") + assert schedule.get_shift_rate() == 50.0 + + def test_default_rate_missing_is_zero(self, schedule): + assert schedule.get_shift_rate() == 0.0 + + def test_per_employee_rate_overrides_default(self, schedule, seed): + seed.config(shift_rate="50") + seed.roster("114", "Alice", shift_rate="75") + assert schedule.get_shift_rate("114") == 75.0 + # Employee without a custom rate falls back to default + seed.roster("115", "Bob") + assert schedule.get_shift_rate("115") == 50.0 + + def test_set_default_shift_rate(self, schedule, seed): + seed.config(shift_rate="50") + schedule.set_default_shift_rate(60) + assert schedule.get_shift_rate() == 60.0 + + def test_set_employee_shift_rate(self, schedule, seed): + seed.roster("114", "Alice") + schedule.set_employee_shift_rate("114", 90) + assert schedule.get_shift_rate("114") == 90.0 + + +class TestPayAndConfigAndPosts: + def test_pay_record_round_trip(self, schedule): + schedule.save_pay_record("2026-05-25", {"breakdown": [], "totals": {}}) + rec = schedule.get_pay_record("2026-05-25") + assert rec["PK"] == "PAY" and rec["SK"] == "2026-05-25" + + def test_get_pay_record_missing(self, schedule): + assert schedule.get_pay_record("1999-01-01") is None + + def test_admin_users(self, schedule, seed): + seed.config(admin_users=["U_ADMIN"]) + assert schedule.get_admin_users() == ["U_ADMIN"] + + def test_admin_users_default_empty(self, schedule): + assert schedule.get_admin_users() == [] + + def test_schedule_post_round_trip(self, schedule): + schedule.save_schedule_post("C123", "111.222", "2026-06-01") + post = schedule.get_schedule_post("C123") + assert post["message_ts"] == "111.222" + assert schedule.get_schedule_post("C_NONE") is None + + +def test_shiftschedule_uses_env_table_name(monkeypatch, dynamodb_table): + # Constructed with no arg → reads SHIFT_TABLE. + assert ShiftSchedule().table.name == "afterhours-shifts" diff --git a/tests/shared/test_secrets.py b/tests/shared/test_secrets.py new file mode 100644 index 0000000..4e72cd6 --- /dev/null +++ b/tests/shared/test_secrets.py @@ -0,0 +1,32 @@ +"""Tests for shared.secrets — Secrets Manager fetch + client caching.""" + +import boto3 +import pytest +from moto import mock_aws + +import shared.secrets as secrets + + +@pytest.fixture(autouse=True) +def _reset_client(): + # The module caches a module-level client; reset around each test so a + # client created under one moto context doesn't leak into another. + secrets._client = None + yield + secrets._client = None + + +def test_get_secret_returns_secret_string(): + with mock_aws(): + sm = boto3.client("secretsmanager", region_name="us-east-1") + sm.create_secret( + Name="afterhours-shift-manager/3cx-domain", SecretString="x.3cx.us" + ) + assert secrets.get_secret("afterhours-shift-manager/3cx-domain") == "x.3cx.us" + + +def test_get_client_is_cached(): + with mock_aws(): + first = secrets._get_client() + second = secrets._get_client() + assert first is second diff --git a/tests/shared/test_three_cx_client.py b/tests/shared/test_three_cx_client.py new file mode 100644 index 0000000..d2313f8 --- /dev/null +++ b/tests/shared/test_three_cx_client.py @@ -0,0 +1,113 @@ +"""Tests for shared.three_cx_client — auth flows and XAPI calls (HTTP mocked).""" + +import responses + +from shared.three_cx_client import ThreeCXClient + +BASE = "https://test.3cx.us" + + +def _stub_oauth(): + responses.add( + responses.POST, + f"{BASE}/connect/token", + json={"access_token": "tok-oauth"}, + status=200, + ) + + +@responses.activate +def test_oauth_authentication_sets_bearer_header(): + _stub_oauth() + client = ThreeCXClient( + domain="test.3cx.us", + auth_mode="oauth", + client_id="cid", + client_secret="secret", + ) + assert client.session.headers["Authorization"] == "Bearer tok-oauth" + + +@responses.activate +def test_user_authentication_extracts_nested_token(): + responses.add( + responses.POST, + f"{BASE}/webclient/api/Login/GetAccessToken", + json={"Token": {"access_token": "tok-user"}}, + status=200, + ) + client = ThreeCXClient(domain="test.3cx.us", username="u", password="p") + assert client.session.headers["Authorization"] == "Bearer tok-user" + + +@responses.activate +def test_user_authentication_missing_token_raises(): + import pytest + + responses.add( + responses.POST, + f"{BASE}/webclient/api/Login/GetAccessToken", + json={"nope": True}, + status=200, + ) + with pytest.raises(ValueError): + ThreeCXClient(domain="test.3cx.us", username="u", password="p") + + +@responses.activate +def test_get_group_members_resolves_group_then_members(): + _stub_oauth() + responses.add( + responses.GET, + f"{BASE}/xapi/v1/Groups", + json={"value": [{"Id": 5, "Name": "DEFAULT"}]}, + status=200, + ) + responses.add( + responses.GET, + f"{BASE}/xapi/v1/Groups(5)/Members", + json={"value": [{"Number": "114", "MemberName": "Alice", "Type": "Extension"}]}, + status=200, + ) + client = ThreeCXClient( + domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s" + ) + members = client.get_group_members("DEFAULT") + assert members == [{"Number": "114", "MemberName": "Alice", "Type": "Extension"}] + + +@responses.activate +def test_get_group_members_unknown_group_returns_empty(): + _stub_oauth() + responses.add( + responses.GET, f"{BASE}/xapi/v1/Groups", json={"value": []}, status=200 + ) + client = ThreeCXClient( + domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s" + ) + assert client.get_group_members("NOPE") == [] + + +@responses.activate +def test_get_queue_and_update_queue_forwarding(): + _stub_oauth() + responses.add( + responses.GET, + f"{BASE}/xapi/v1/Queues/Pbx.GetByNumber(number='800')", + json={"Id": 7, "Number": "800"}, + status=200, + ) + patched = responses.add(responses.PATCH, f"{BASE}/xapi/v1/Queues(7)", status=200) + client = ThreeCXClient( + domain="test.3cx.us", auth_mode="oauth", client_id="c", client_secret="s" + ) + queue = client.get_queue("800") + assert queue["Id"] == 7 + status = client.update_queue_forwarding(queue_id=7, closed="114", holiday="114") + assert status == 200 + # The forwarding payload routes both closed and holiday to the extension. + import json + + body = json.loads(patched.calls[0].request.body) + assert body["OutOfOfficeRoute"]["Route"]["Number"] == "114" + assert body["HolidaysRoute"]["Route"]["Number"] == "114" diff --git a/tests/slack_bot/conftest.py b/tests/slack_bot/conftest.py new file mode 100644 index 0000000..68ef287 --- /dev/null +++ b/tests/slack_bot/conftest.py @@ -0,0 +1,53 @@ +"""Load src/slack-bot/app.py under a unique module name so it doesn't collide +with the other Lambdas' app.py modules in sys.modules, plus common test doubles.""" + +import importlib.util +import pathlib +import sys +from unittest.mock import MagicMock + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def slackbot_app(): + return _load("slackbot_app", "src/slack-bot/app.py") + + +@pytest.fixture +def respond(): + return MagicMock(name="respond") + + +@pytest.fixture +def client(): + return MagicMock(name="client") + + +@pytest.fixture +def routing_spy(slackbot_app, monkeypatch): + """Replace the 3CX routing call with a spy so tests can assert on it + without touching the network.""" + spy = MagicMock(name="_update_3cx_routing") + monkeypatch.setattr(slackbot_app, "_update_3cx_routing", spy) + return spy + + +@pytest.fixture +def text_of(): + """Helper: pull the `text=` kwarg from a mock's most recent call.""" + + def _get(mock): + return mock.call_args.kwargs.get("text", "") + + return _get diff --git a/tests/slack_bot/test_handle_admin.py b/tests/slack_bot/test_handle_admin.py new file mode 100644 index 0000000..c8b5bb2 --- /dev/null +++ b/tests/slack_bot/test_handle_admin.py @@ -0,0 +1,90 @@ +"""Tests for slack-bot _handle_admin.""" + +from freezegun import freeze_time + +from shared.schedule import FALLBACK_EXTENSION + +MON = "2026-06-01 12:00:00" + + +def _admin(slackbot_app, schedule, text, respond, client): + slackbot_app._handle_admin(respond, schedule, "U_ADMIN", text, True, client, None) + + +def test_non_admin_rejected(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_admin( + respond, schedule, "U_USER", "admin open today", False, client, None + ) + assert "restricted" in text_of(respond).lower() + + +def test_admin_help(slackbot_app, schedule, respond, client, text_of): + _admin(slackbot_app, schedule, "admin", respond, client) + assert "Admin Commands" in text_of(respond) + + +@freeze_time(MON) +def test_override(slackbot_app, schedule, seed, respond, client, routing_spy, text_of): + seed.roster("114", "Alice") + _admin(slackbot_app, schedule, "admin override today 114", respond, client) + assert schedule.get_override("2026-06-01")["extension"] == "114" + assert "Override set" in text_of(respond) + routing_spy.assert_called_once_with("114") + + +def test_override_unknown_extension(slackbot_app, schedule, respond, client, text_of): + _admin(slackbot_app, schedule, "admin override today 999", respond, client) + assert "not found" in text_of(respond).lower() + + +@freeze_time(MON) +def test_open(slackbot_app, schedule, seed, respond, client, routing_spy, text_of): + seed.weekly("Monday", "114", "Alice") + _admin(slackbot_app, schedule, "admin open today", respond, client) + assert schedule.get_override("2026-06-01")["extension"] == "OPEN" + assert "marked as open" in text_of(respond).lower() + routing_spy.assert_called_once_with(FALLBACK_EXTENSION) + + +@freeze_time(MON) +def test_clear(slackbot_app, schedule, seed, respond, client, routing_spy, text_of): + seed.override("2026-06-01", "115", "Bob") + _admin(slackbot_app, schedule, "admin clear today", respond, client) + assert schedule.get_override("2026-06-01") is None + assert "cleared" in text_of(respond).lower() + + +def test_roster_add(slackbot_app, schedule, respond, client, text_of): + _admin(slackbot_app, schedule, "admin roster add 116 New Person", respond, client) + assert schedule.get_employee_by_extension("116")["name"] == "New Person" + assert "Added" in text_of(respond) + + +def test_roster_add_duplicate(slackbot_app, schedule, seed, respond, client, text_of): + seed.roster("116", "Existing") + _admin(slackbot_app, schedule, "admin roster add 116 New Person", respond, client) + assert "already exists" in text_of(respond).lower() + + +def test_roster_remove(slackbot_app, schedule, seed, respond, client, text_of): + seed.roster("114", "Alice") + _admin(slackbot_app, schedule, "admin roster remove 114", respond, client) + assert schedule.get_employee_by_extension("114") is None + assert "Removed" in text_of(respond) + + +def test_roster_remove_unknown(slackbot_app, schedule, respond, client, text_of): + _admin(slackbot_app, schedule, "admin roster remove 999", respond, client) + assert "not found" in text_of(respond).lower() + + +def test_roster_rename(slackbot_app, schedule, seed, respond, client, text_of): + seed.roster("114", "Alice") + _admin(slackbot_app, schedule, "admin roster rename 114 Alicia", respond, client) + assert schedule.get_employee_by_extension("114")["name"] == "Alicia" + assert "Renamed" in text_of(respond) + + +def test_unknown_subcommand(slackbot_app, schedule, respond, client, text_of): + _admin(slackbot_app, schedule, "admin frobnicate", respond, client) + assert "Unknown admin command" in text_of(respond) diff --git a/tests/slack_bot/test_handle_drop.py b/tests/slack_bot/test_handle_drop.py new file mode 100644 index 0000000..e399097 --- /dev/null +++ b/tests/slack_bot/test_handle_drop.py @@ -0,0 +1,89 @@ +"""Tests for slack-bot _handle_drop.""" + +from freezegun import freeze_time + +from shared.schedule import FALLBACK_EXTENSION + +# Monday 2026-06-01 08:00 ET — weekday, active shift is night. +MON = "2026-06-01 12:00:00" + + +def _register_on_monday(seed): + """Alice (114) is registered and assigned the Monday night shift.""" + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Monday", "114", "Alice") + + +@freeze_time(MON) +def test_drop_today_marks_open_and_repoints_3cx( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + _register_on_monday(seed) + slackbot_app._handle_drop( + respond, schedule, "U_ALICE", "drop today", "C1", client, None + ) + + assert schedule.get_override("2026-06-01")["extension"] == "OPEN" + assert "dropped" in text_of(respond) + # Today + active night shift → queue falls back. + routing_spy.assert_called_once_with(FALLBACK_EXTENSION) + client.chat_postMessage.assert_called_once() + + +@freeze_time(MON) +def test_drop_future_does_not_repoint_3cx( + slackbot_app, schedule, seed, respond, client, routing_spy +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Tuesday", "114", "Alice") + slackbot_app._handle_drop( + respond, schedule, "U_ALICE", "drop tomorrow", "C1", client, None + ) + + assert schedule.get_override("2026-06-02")["extension"] == "OPEN" + routing_spy.assert_not_called() + + +@freeze_time(MON) +def test_drop_not_your_shift( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Monday", "115", "Bob") # Bob is on shift, not Alice + slackbot_app._handle_drop( + respond, schedule, "U_ALICE", "drop today", "C1", client, None + ) + assert "not your shift" in text_of(respond).lower() + routing_spy.assert_not_called() + + +@freeze_time(MON) +def test_drop_past_date(slackbot_app, schedule, seed, respond, client, text_of): + _register_on_monday(seed) + slackbot_app._handle_drop( + respond, schedule, "U_ALICE", "drop 2026-05-01", "C1", client, None + ) + assert "past" in text_of(respond).lower() + + +@freeze_time(MON) +def test_drop_unregistered(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_drop( + respond, schedule, "U_NOBODY", "drop today", "C1", client, None + ) + assert "not registered" in text_of(respond).lower() + + +@freeze_time(MON) +def test_drop_bad_date(slackbot_app, schedule, seed, respond, client, text_of): + _register_on_monday(seed) + slackbot_app._handle_drop( + respond, schedule, "U_ALICE", "drop notaday", "C1", client, None + ) + assert "couldn't parse" in text_of(respond).lower() + + +@freeze_time(MON) +def test_drop_usage(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_drop(respond, schedule, "U_ALICE", "drop", "C1", client, None) + assert "Usage" in text_of(respond) diff --git a/tests/slack_bot/test_handle_pick.py b/tests/slack_bot/test_handle_pick.py new file mode 100644 index 0000000..84cc29f --- /dev/null +++ b/tests/slack_bot/test_handle_pick.py @@ -0,0 +1,55 @@ +"""Tests for slack-bot _handle_pick.""" + +from freezegun import freeze_time + +MON = "2026-06-01 12:00:00" + + +@freeze_time(MON) +def test_pick_open_shift_today( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + # Monday night is unassigned (available) → pickable. + slackbot_app._handle_pick( + respond, schedule, "U_ALICE", "pick today", "C1", client, None + ) + assert schedule.get_override("2026-06-01")["extension"] == "114" + assert "picked up" in text_of(respond).lower() + routing_spy.assert_called_once_with("114") + + +@freeze_time(MON) +def test_pick_already_covered( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Monday", "115", "Bob") # already covered + slackbot_app._handle_pick( + respond, schedule, "U_ALICE", "pick today", "C1", client, None + ) + assert "already covered" in text_of(respond).lower() + routing_spy.assert_not_called() + + +@freeze_time(MON) +def test_pick_past_date(slackbot_app, schedule, seed, respond, client, text_of): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + slackbot_app._handle_pick( + respond, schedule, "U_ALICE", "pick 2026-05-01", "C1", client, None + ) + assert "past" in text_of(respond).lower() + + +@freeze_time(MON) +def test_pick_unregistered(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_pick( + respond, schedule, "U_NOBODY", "pick today", "C1", client, None + ) + assert "not registered" in text_of(respond).lower() + + +@freeze_time(MON) +def test_pick_usage(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_pick(respond, schedule, "U_ALICE", "pick", "C1", client, None) + assert "Usage" in text_of(respond) diff --git a/tests/slack_bot/test_handle_register_rate.py b/tests/slack_bot/test_handle_register_rate.py new file mode 100644 index 0000000..b262f7b --- /dev/null +++ b/tests/slack_bot/test_handle_register_rate.py @@ -0,0 +1,64 @@ +"""Tests for slack-bot _handle_register and _handle_rate.""" + + +class TestRegister: + def test_register_links_account( + self, slackbot_app, schedule, seed, respond, text_of + ): + seed.roster("114", "Alice") + slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 114") + assert "Linked" in text_of(respond) + assert schedule.get_employee_by_extension("114")["slack_user_id"] == "U_ALICE" + + def test_register_unknown_extension(self, slackbot_app, schedule, respond, text_of): + slackbot_app._handle_register(respond, schedule, "U_ALICE", "register 999") + assert "not found" in text_of(respond).lower() + + def test_register_usage(self, slackbot_app, schedule, respond, text_of): + slackbot_app._handle_register(respond, schedule, "U_ALICE", "register") + assert "Usage" in text_of(respond) + + +class TestRate: + def test_show_rates(self, slackbot_app, schedule, seed, respond, text_of): + seed.config(shift_rate="50") + seed.roster("114", "Alice", shift_rate="75") + slackbot_app._handle_rate(respond, schedule, "rate") + text = text_of(respond) + assert "$50.00" in text and "Alice" in text and "$75.00" in text + + def test_show_rates_no_custom(self, slackbot_app, schedule, seed, respond, text_of): + seed.config(shift_rate="50") + slackbot_app._handle_rate(respond, schedule, "rate") + assert "No per-person rates" in text_of(respond) + + def test_set_default(self, slackbot_app, schedule, seed, respond, text_of): + seed.config(shift_rate="50") + slackbot_app._handle_rate(respond, schedule, "rate default 60") + assert schedule.get_shift_rate() == 60.0 + assert "$60.00" in text_of(respond) + + def test_set_default_invalid_amount(self, slackbot_app, schedule, respond, text_of): + slackbot_app._handle_rate(respond, schedule, "rate default abc") + assert "Invalid amount" in text_of(respond) + + def test_set_default_usage(self, slackbot_app, schedule, respond, text_of): + slackbot_app._handle_rate(respond, schedule, "rate default") + assert "Usage" in text_of(respond) + + def test_set_per_employee(self, slackbot_app, schedule, seed, respond, text_of): + seed.roster("114", "Alice") + slackbot_app._handle_rate(respond, schedule, "rate 114 90") + assert schedule.get_shift_rate("114") == 90.0 + assert "Alice" in text_of(respond) and "$90.00" in text_of(respond) + + def test_set_per_employee_unknown(self, slackbot_app, schedule, respond, text_of): + slackbot_app._handle_rate(respond, schedule, "rate 999 90") + assert "not found" in text_of(respond).lower() + + def test_set_per_employee_strips_dollar_sign( + self, slackbot_app, schedule, seed, respond + ): + seed.roster("114", "Alice") + slackbot_app._handle_rate(respond, schedule, "rate 114 $90") + assert schedule.get_shift_rate("114") == 90.0 diff --git a/tests/slack_bot/test_handle_swap.py b/tests/slack_bot/test_handle_swap.py new file mode 100644 index 0000000..0f25613 --- /dev/null +++ b/tests/slack_bot/test_handle_swap.py @@ -0,0 +1,98 @@ +"""Tests for slack-bot _handle_swap.""" + +from freezegun import freeze_time + +MON = "2026-06-01 12:00:00" + + +def _setup(seed): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster("115", "Bob", slack_user_id="U_BOB") + seed.weekly("Monday", "114", "Alice") # Alice is on the Monday shift + + +@freeze_time(MON) +def test_swap_to_mention( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + _setup(seed) + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, None + ) + override = schedule.get_override("2026-06-01") + assert override["extension"] == "115" and override["name"] == "Bob" + assert "Swapped" in text_of(respond) + routing_spy.assert_called_once_with("115") + client.chat_postMessage.assert_called_once() + + +@freeze_time(MON) +def test_swap_to_extension(slackbot_app, schedule, seed, respond, client, routing_spy): + _setup(seed) + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today 115", "C1", client, None + ) + assert schedule.get_override("2026-06-01")["extension"] == "115" + + +@freeze_time(MON) +def test_swap_not_your_shift( + slackbot_app, schedule, seed, respond, client, routing_spy, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.roster("115", "Bob", slack_user_id="U_BOB") + seed.weekly("Monday", "115", "Bob") # Bob's shift, not Alice's + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today <@U_BOB>", "C1", client, None + ) + assert "not your shift" in text_of(respond).lower() + routing_spy.assert_not_called() + + +@freeze_time(MON) +def test_swap_target_mention_unregistered( + slackbot_app, schedule, seed, respond, client, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Monday", "114", "Alice") + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today <@U_GHOST>", "C1", client, None + ) + assert "isn't registered" in text_of(respond) + + +@freeze_time(MON) +def test_swap_target_extension_not_found( + slackbot_app, schedule, seed, respond, client, text_of +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.weekly("Monday", "114", "Alice") + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today 999", "C1", client, None + ) + assert "not found" in text_of(respond).lower() + + +@freeze_time(MON) +def test_swap_past_date(slackbot_app, schedule, seed, respond, client, text_of): + _setup(seed) + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap 2026-05-01 <@U_BOB>", "C1", client, None + ) + assert "past" in text_of(respond).lower() + + +@freeze_time(MON) +def test_swap_unregistered_requester(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_swap( + respond, schedule, "U_NOBODY", "swap today 115", "C1", client, None + ) + assert "not registered" in text_of(respond).lower() + + +@freeze_time(MON) +def test_swap_usage(slackbot_app, schedule, respond, client, text_of): + slackbot_app._handle_swap( + respond, schedule, "U_ALICE", "swap today", "C1", client, None + ) + assert "Usage" in text_of(respond) diff --git a/tests/slack_bot/test_helpers.py b/tests/slack_bot/test_helpers.py new file mode 100644 index 0000000..c3f1113 --- /dev/null +++ b/tests/slack_bot/test_helpers.py @@ -0,0 +1,63 @@ +"""Tests for slack-bot module-level helpers.""" + +from freezegun import freeze_time + +# Monday 2026-06-01 08:00 ET → weekday, active shift is night. +MON = "2026-06-01 12:00:00" +SAT_DATE = "2026-06-06" + + +class TestIsToday: + @freeze_time(MON) + def test_true_for_today(self, slackbot_app): + assert slackbot_app.is_today("2026-06-01") is True + + @freeze_time(MON) + def test_false_for_other_day(self, slackbot_app): + assert slackbot_app.is_today("2026-06-02") is False + + +class TestIsActiveShiftType: + @freeze_time(MON) + def test_weekday_active_is_night(self, slackbot_app): + assert slackbot_app._is_active_shift_type("night") is True + assert slackbot_app._is_active_shift_type("day") is False + + +class TestShiftTypeLabel: + def test_weekday_has_no_label(self, slackbot_app): + assert slackbot_app._shift_type_label("Wednesday", "night") == "" + + def test_weekend_day(self, slackbot_app): + assert slackbot_app._shift_type_label("Saturday", "day") == " (Day)" + + def test_weekend_night(self, slackbot_app): + assert slackbot_app._shift_type_label("Saturday", "night") == " (Night)" + + +class TestFindEmployeeShift: + def test_weekday_night_match(self, slackbot_app, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + found = slackbot_app._find_employee_shift( + schedule, "2026-06-03", "Wednesday", "114" + ) + assert found == ("114", "Alice", "weekly", "night") + + def test_weekend_day_match(self, slackbot_app, schedule, seed): + seed.weekly("Saturday", "200", "Alice", shift_type="day") + found = slackbot_app._find_employee_shift(schedule, SAT_DATE, "Saturday", "200") + assert found == ("200", "Alice", "weekly", "day") + + def test_weekend_night_match(self, slackbot_app, schedule, seed): + seed.weekly("Saturday", "201", "Bob", shift_type="night") + found = slackbot_app._find_employee_shift(schedule, SAT_DATE, "Saturday", "201") + assert found == ("201", "Bob", "weekly", "night") + + def test_not_assigned_returns_none(self, slackbot_app, schedule, seed): + seed.weekly("Wednesday", "114", "Alice") + assert ( + slackbot_app._find_employee_shift( + schedule, "2026-06-03", "Wednesday", "999" + ) + is None + ) diff --git a/tests/slack_bot/test_parse_date.py b/tests/slack_bot/test_parse_date.py new file mode 100644 index 0000000..10be6f2 --- /dev/null +++ b/tests/slack_bot/test_parse_date.py @@ -0,0 +1,59 @@ +"""Tests for slack-bot parse_date — flexible date parsing.""" + +from freezegun import freeze_time + +# All frozen to Monday 2026-06-01 (ET 08:00). +FROZEN = "2026-06-01 12:00:00" + + +@freeze_time(FROZEN) +def test_today(slackbot_app): + assert slackbot_app.parse_date("today").strftime("%Y-%m-%d") == "2026-06-01" + + +@freeze_time(FROZEN) +def test_tomorrow(slackbot_app): + assert slackbot_app.parse_date("tomorrow").strftime("%Y-%m-%d") == "2026-06-02" + + +@freeze_time(FROZEN) +def test_future_day_name(slackbot_app): + # Friday of the same week. + assert slackbot_app.parse_date("friday").strftime("%Y-%m-%d") == "2026-06-05" + + +@freeze_time(FROZEN) +def test_same_day_name_jumps_a_week(slackbot_app): + # Monday on a Monday → next Monday, never today. + assert slackbot_app.parse_date("monday").strftime("%Y-%m-%d") == "2026-06-08" + + +@freeze_time(FROZEN) +def test_iso_date(slackbot_app): + assert slackbot_app.parse_date("2026-07-04").strftime("%Y-%m-%d") == "2026-07-04" + + +@freeze_time(FROZEN) +def test_month_slash_day(slackbot_app): + assert slackbot_app.parse_date("7/4").strftime("%Y-%m-%d") == "2026-07-04" + + +@freeze_time(FROZEN) +def test_month_dash_day(slackbot_app): + assert slackbot_app.parse_date("7-4").strftime("%Y-%m-%d") == "2026-07-04" + + +@freeze_time(FROZEN) +def test_past_month_day_wraps_to_next_year(slackbot_app): + # Jan 1 is well in the past → assume next year. + assert slackbot_app.parse_date("1/1").year == 2027 + + +@freeze_time(FROZEN) +def test_case_insensitive(slackbot_app): + assert slackbot_app.parse_date(" FRIDAY ").strftime("%Y-%m-%d") == "2026-06-05" + + +@freeze_time(FROZEN) +def test_invalid_returns_none(slackbot_app): + assert slackbot_app.parse_date("notaday") is None diff --git a/tests/slack_bot/test_pickup_button.py b/tests/slack_bot/test_pickup_button.py new file mode 100644 index 0000000..dd47896 --- /dev/null +++ b/tests/slack_bot/test_pickup_button.py @@ -0,0 +1,73 @@ +"""Tests for slack-bot handle_pickup (interactive button).""" + +from freezegun import freeze_time + +MON = "2026-06-01 12:00:00" + + +def _body(action_id, user_id="U_ALICE", channel="C1"): + return { + "actions": [{"action_id": action_id}], + "user": {"id": user_id}, + "channel": {"id": channel}, + } + + +@freeze_time(MON) +def test_pickup_today_success( + slackbot_app, schedule, seed, respond, client, routing_spy +): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + slackbot_app.handle_pickup( + _body("pickup_2026-06-01"), respond, client, schedule, None + ) + assert schedule.get_override("2026-06-01")["extension"] == "114" + # Posted to channel and repointed (today + active night). + assert respond.call_args.kwargs.get("response_type") == "in_channel" + routing_spy.assert_called_once_with("114") + + +@freeze_time(MON) +def test_pickup_day_shift_action_id(slackbot_app, schedule, seed, respond, client): + seed.roster("200", "Alice", slack_user_id="U_ALICE") + slackbot_app.handle_pickup( + _body("pickup_2026-06-06_day"), respond, client, schedule, None + ) + # Saturday day-shift override is stored under the -DAY SK. + assert schedule.get_override("2026-06-06", "day")["extension"] == "200" + + +@freeze_time(MON) +def test_pickup_past_shift_rejected(slackbot_app, schedule, seed, respond, client): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + slackbot_app.handle_pickup( + _body("pickup_2026-05-01"), respond, client, schedule, None + ) + client.chat_postEphemeral.assert_called_once() + assert "already passed" in client.chat_postEphemeral.call_args.kwargs["text"] + assert schedule.get_override("2026-05-01") is None + + +@freeze_time(MON) +def test_pickup_unregistered(slackbot_app, schedule, respond, client): + slackbot_app.handle_pickup( + _body("pickup_2026-06-03", user_id="U_NOBODY"), respond, client, schedule, None + ) + assert ( + "not registered" in client.chat_postEphemeral.call_args.kwargs["text"].lower() + ) + + +@freeze_time(MON) +def test_pickup_already_taken(slackbot_app, schedule, seed, respond, client): + seed.roster("114", "Alice", slack_user_id="U_ALICE") + seed.override("2026-06-03", "115", "Bob") # already claimed + slackbot_app.handle_pickup( + _body("pickup_2026-06-03"), respond, client, schedule, None + ) + assert ( + "already picked up" + in client.chat_postEphemeral.call_args.kwargs["text"].lower() + ) + # Bob keeps the shift. + assert schedule.get_override("2026-06-03")["extension"] == "115" diff --git a/tests/weekly_post/conftest.py b/tests/weekly_post/conftest.py new file mode 100644 index 0000000..b8da0d2 --- /dev/null +++ b/tests/weekly_post/conftest.py @@ -0,0 +1,22 @@ +"""Load src/weekly-post/app.py under a unique module name.""" + +import importlib.util +import pathlib +import sys + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def _load(name, relpath): + spec = importlib.util.spec_from_file_location(name, _ROOT / relpath) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def weeklypost_app(): + return _load("weeklypost_app", "src/weekly-post/app.py") diff --git a/tests/weekly_post/test_handler.py b/tests/weekly_post/test_handler.py new file mode 100644 index 0000000..cb80609 --- /dev/null +++ b/tests/weekly_post/test_handler.py @@ -0,0 +1,76 @@ +"""Tests for the weekly-post Lambda handler orchestration.""" + +from unittest.mock import MagicMock + +import pytest +from freezegun import freeze_time + +# 2026-06-08 is a Monday. Frozen to ET 08:00; handler is invoked with force=True +# to bypass the 7am DST guard except where the guard itself is under test. +MON_0800 = "2026-06-08 12:00:00" + + +@pytest.fixture +def slack(weeklypost_app, monkeypatch): + """Fake Slack WebClient; chat_postMessage returns a message ts.""" + fake = MagicMock(name="slack") + fake.chat_postMessage.return_value = {"ts": "999.000"} + monkeypatch.setattr(weeklypost_app, "WebClient", MagicMock(return_value=fake)) + monkeypatch.setattr(weeklypost_app, "get_secret", lambda _id: "xoxb-test") + return fake + + +@pytest.fixture +def env(monkeypatch): + monkeypatch.setenv( + "SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token" + ) + monkeypatch.setenv("PAY_REPORT_USER", "U_BOSS") + monkeypatch.delenv("PAYROLL_RECIPIENTS", raising=False) # skip SES email + + +@freeze_time(MON_0800) +def test_posts_schedule_and_saves_post(weeklypost_app, schedule, seed, slack, env): + result = weeklypost_app.handler({"force": True}, None) + + assert result["posted"] is True + assert result["message_ts"] == "999.000" + # The new schedule post was persisted for next week's cleanup. + assert schedule.get_schedule_post("C_TEST")["message_ts"] == "999.000" + slack.chat_postMessage.assert_called() + + +@freeze_time(MON_0800) +def test_calculates_and_dms_pay(weeklypost_app, schedule, seed, slack, env): + # Previous week (Mon 2026-06-01) had Alice on the Monday night shift. + seed.config(shift_rate="50") + seed.weekly("Monday", "114", "Alice") + + result = weeklypost_app.handler({"force": True}, None) + + assert result["pay_calculated"] is True + # Pay record saved under previous Monday's key. + assert schedule.get_pay_record("2026-06-01") is not None + # A DM went to the configured pay-report user. + dm_calls = [ + c + for c in slack.chat_postMessage.call_args_list + if c.kwargs.get("channel") == "U_BOSS" + ] + assert dm_calls + + +@freeze_time(MON_0800) +def test_deletes_previous_schedule_post(weeklypost_app, schedule, seed, slack, env): + seed.schedule_post("C_TEST", "111.111") + weeklypost_app.handler({"force": True}, None) + slack.chat_delete.assert_called_once() + assert slack.chat_delete.call_args.kwargs["ts"] == "111.111" + + +@freeze_time(MON_0800) +def test_skips_when_not_7am_and_not_forced(weeklypost_app, schedule, slack, env): + # Frozen hour is 08:00 ET, not 07:00 → skip unless forced. + result = weeklypost_app.handler({}, None) + assert result == {"skipped": True} + slack.chat_postMessage.assert_not_called() diff --git a/tests/weekly_post/test_pay_math.py b/tests/weekly_post/test_pay_math.py new file mode 100644 index 0000000..905f9f4 --- /dev/null +++ b/tests/weekly_post/test_pay_math.py @@ -0,0 +1,83 @@ +"""Tests for weekly-post pay calculation helpers.""" + +from datetime import datetime +from decimal import Decimal + +# 2026-06-01 is a Monday — required so DAY_ORDER lines up with real weekdays. +WEEK_START = datetime(2026, 6, 1) + + +class TestAddShiftToPay: + def test_assigned_shift_is_added(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + seed.weekly("Monday", "114", "Alice") + breakdown, totals = [], {} + weeklypost_app._add_shift_to_pay( + schedule, breakdown, totals, datetime(2026, 6, 1), "Monday" + ) + assert len(breakdown) == 1 + assert breakdown[0]["name"] == "Alice" + assert breakdown[0]["rate"] == Decimal("50") + assert totals["Alice"]["shifts"] == 1 + assert totals["Alice"]["total"] == Decimal("50") + + def test_available_shift_is_skipped(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + breakdown, totals = [], {} + weeklypost_app._add_shift_to_pay( + schedule, breakdown, totals, datetime(2026, 6, 1), "Monday" + ) + assert breakdown == [] and totals == {} + + def test_weekend_day_label(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + seed.weekly("Saturday", "200", "Alice", shift_type="day") + breakdown, totals = [], {} + weeklypost_app._add_shift_to_pay( + schedule, breakdown, totals, datetime(2026, 6, 6), "Saturday", "day" + ) + assert breakdown[0]["day"] == "Sat (Day)" + + +class TestCalculateWeeklyPay: + def test_single_weekday_shift(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + seed.weekly("Monday", "114", "Alice") + record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START) + assert record["week_start"] == "2026-06-01" + assert len(record["breakdown"]) == 1 + assert record["totals"]["Alice"]["total"] == Decimal("50") + + def test_per_employee_rate_applied(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + seed.roster("114", "Alice", shift_rate="75") + seed.weekly("Monday", "114", "Alice") + record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START) + assert record["totals"]["Alice"]["total"] == Decimal("75.0") + + def test_weekend_counts_day_and_night(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + seed.weekly("Saturday", "200", "Alice", shift_type="day") + seed.weekly("Saturday", "200", "Alice", shift_type="night") + record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START) + # Alice worked both Saturday shifts → 2 shifts, $100. + assert record["totals"]["Alice"]["shifts"] == 2 + assert record["totals"]["Alice"]["total"] == Decimal("100") + + def test_empty_week_has_no_breakdown(self, weeklypost_app, schedule, seed): + seed.config(shift_rate="50") + record = weeklypost_app._calculate_weekly_pay(schedule, WEEK_START) + assert record["breakdown"] == [] and record["totals"] == {} + + +class TestBuildPayEmailHtml: + def test_renders_totals_rows(self, weeklypost_app): + pay_record = { + "totals": { + "Alice": {"total": Decimal("100"), "rate": Decimal("50"), "shifts": 2} + } + } + html = weeklypost_app._build_pay_email_html("Jun 1 to Jun 7", pay_record) + assert "Jun 1 to Jun 7" in html + assert "Alice" in html + assert "$100.00" in html