mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 03:23:12 +00:00
* 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 * Add least-privilege permissions block to CI workflow Resolves the CodeQL actions/missing-workflow-permissions alert: the CI workflow now restricts GITHUB_TOKEN to contents: read (it only checks out, lints, and runs tests). * Stop logging extension numbers in 3CX queue updates Resolves 3 high CodeQL py/clear-text-logging-sensitive-data alerts: the queue/ring-group forwarding logs no longer include the routed extension values (closed/holiday/extension). Non-sensitive context (resource id, queue number) is retained.
This commit is contained in:
parent
9af1a9a161
commit
3a26343cb7
30 changed files with 2353 additions and 632 deletions
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
|
|
@ -3,8 +3,12 @@ on:
|
|||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,6 +2,8 @@ __pycache__/
|
|||
*.pyc
|
||||
.aws-sam/
|
||||
venv/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.env
|
||||
samconfig.toml
|
||||
output.json
|
||||
|
|
|
|||
20
README.md
20
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.
|
||||
|
|
|
|||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal file
|
|
@ -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"
|
||||
|
|
@ -27,5 +27,5 @@ def update_queue_routing(
|
|||
closed=extension,
|
||||
holiday=extension,
|
||||
)
|
||||
logger.info("Updated queue %s to forward to Ext %s", queue_number, extension)
|
||||
logger.info("Updated queue %s forwarding", queue_number)
|
||||
return {"extension": extension, "queue": queue_number}
|
||||
|
|
|
|||
|
|
@ -102,13 +102,7 @@ class ThreeCXClient:
|
|||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(
|
||||
"Updated %s %s: closed->Ext %s, holiday->Ext %s",
|
||||
resource,
|
||||
resource_id,
|
||||
closed,
|
||||
holiday,
|
||||
)
|
||||
logger.info("Updated %s %s forwarding", resource, resource_id)
|
||||
return resp.status_code
|
||||
|
||||
def get_ring_group(self, extension_number: str) -> dict:
|
||||
|
|
|
|||
1268
src/slack-bot/app.py
1268
src/slack-bot/app.py
File diff suppressed because it is too large
Load diff
116
tests/conftest.py
Normal file
116
tests/conftest.py
Normal file
|
|
@ -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)
|
||||
7
tests/requirements.txt
Normal file
7
tests/requirements.txt
Normal file
|
|
@ -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
|
||||
22
tests/ring_scheduler/conftest.py
Normal file
22
tests/ring_scheduler/conftest.py
Normal file
|
|
@ -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")
|
||||
61
tests/ring_scheduler/test_handler.py
Normal file
61
tests/ring_scheduler/test_handler.py
Normal file
|
|
@ -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()
|
||||
22
tests/roster_sync/conftest.py
Normal file
22
tests/roster_sync/conftest.py
Normal file
|
|
@ -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")
|
||||
91
tests/roster_sync/test_handler.py
Normal file
91
tests/roster_sync/test_handler.py
Normal file
|
|
@ -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}
|
||||
120
tests/shared/test_blocks.py
Normal file
120
tests/shared/test_blocks.py
Normal file
|
|
@ -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
|
||||
43
tests/shared/test_ring_scheduler.py
Normal file
43
tests/shared/test_ring_scheduler.py
Normal file
|
|
@ -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"}
|
||||
220
tests/shared/test_schedule.py
Normal file
220
tests/shared/test_schedule.py
Normal file
|
|
@ -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"
|
||||
32
tests/shared/test_secrets.py
Normal file
32
tests/shared/test_secrets.py
Normal file
|
|
@ -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
|
||||
113
tests/shared/test_three_cx_client.py
Normal file
113
tests/shared/test_three_cx_client.py
Normal file
|
|
@ -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"
|
||||
53
tests/slack_bot/conftest.py
Normal file
53
tests/slack_bot/conftest.py
Normal file
|
|
@ -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
|
||||
90
tests/slack_bot/test_handle_admin.py
Normal file
90
tests/slack_bot/test_handle_admin.py
Normal file
|
|
@ -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)
|
||||
89
tests/slack_bot/test_handle_drop.py
Normal file
89
tests/slack_bot/test_handle_drop.py
Normal file
|
|
@ -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)
|
||||
55
tests/slack_bot/test_handle_pick.py
Normal file
55
tests/slack_bot/test_handle_pick.py
Normal file
|
|
@ -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)
|
||||
64
tests/slack_bot/test_handle_register_rate.py
Normal file
64
tests/slack_bot/test_handle_register_rate.py
Normal file
|
|
@ -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
|
||||
98
tests/slack_bot/test_handle_swap.py
Normal file
98
tests/slack_bot/test_handle_swap.py
Normal file
|
|
@ -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)
|
||||
63
tests/slack_bot/test_helpers.py
Normal file
63
tests/slack_bot/test_helpers.py
Normal file
|
|
@ -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
|
||||
)
|
||||
59
tests/slack_bot/test_parse_date.py
Normal file
59
tests/slack_bot/test_parse_date.py
Normal file
|
|
@ -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
|
||||
73
tests/slack_bot/test_pickup_button.py
Normal file
73
tests/slack_bot/test_pickup_button.py
Normal file
|
|
@ -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"
|
||||
22
tests/weekly_post/conftest.py
Normal file
22
tests/weekly_post/conftest.py
Normal file
|
|
@ -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")
|
||||
76
tests/weekly_post/test_handler.py
Normal file
76
tests/weekly_post/test_handler.py
Normal file
|
|
@ -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()
|
||||
83
tests/weekly_post/test_pay_math.py
Normal file
83
tests/weekly_post/test_pay_math.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue