Compare commits

...

5 commits

Author SHA1 Message Date
Adam Moussa
54ad5a7e34
fix(side-effects): keep non-prod off Slack and 3CX (DEV-306) (#287)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* fix(side-effects): keep non-prod off Slack and 3CX

Dev portal actions could still name the production Slack channel and phone queue. Skip those calls unless STAGE is prod, and leave the identifiers empty on non-prod tasks.

* style: apply formatter

---------

Co-authored-by: sea-haven-auto-fix[bot] <332630863+sea-haven-auto-fix[bot]@users.noreply.github.com>
2026-09-29 19:10:21 +00:00
renovate[bot]
3030b134fa
chore(deps): update sea-haven-industries/.github action to v1.0.19 (#285)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
* chore(deps): update sea-haven-industries/.github action to v1.0.19

* test(ci): expect org workflow pin v1.0.19

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Adam Moussa <adam@seahavenind.com>
2026-09-28 16:45:27 +00:00
renovate[bot]
9ca1ef48bd
chore(deps): update dependency @redocly/cli to v2.54.2 (#286)
Some checks are pending
Deploy API / Deploy API to dev (push) Waiting to run
Deploy API / Deploy API to prod (push) Waiting to run
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 16:05:07 +00:00
Adam Moussa
e9893a6f7b
docs(agents): drop security review gates (#284)
Agents no longer treat a security review or a cross-family review as a merge gate.
2026-09-26 17:17:13 -04:00
Adam Moussa
edfa34bfbf
feat(portal): store an optional note on swap requests (IP-132) (#283)
Some checks failed
Deploy API / Deploy API to dev (push) Has been cancelled
Deploy API / Deploy API to prod (push) Has been cancelled
* feat(portal): store an optional note on swap requests (IP-132)

* fix(portal): address review feedback

* fix(portal): address review feedback
2026-09-25 20:42:19 +00:00
26 changed files with 307 additions and 49 deletions

View file

@ -13,7 +13,7 @@ permissions:
jobs:
autofix:
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
permissions:
contents: write
secrets: inherit
@ -24,7 +24,7 @@ jobs:
lint:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
with:
python-version: "3.12"
@ -59,7 +59,7 @@ jobs:
terraform:
needs: autofix
if: always() && !cancelled() && (needs.autofix.result == 'skipped' || needs.autofix.outputs.committed != 'true')
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
with:
terraform-version: "1.16.0"

View file

@ -7,4 +7,4 @@ permissions:
jobs:
review:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

View file

@ -43,7 +43,7 @@ jobs:
deploy-dev:
name: Deploy API to dev
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'dev')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
permissions:
contents: read
id-token: write
@ -57,7 +57,7 @@ jobs:
deploy-prod:
name: Deploy API to prod
if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.environment == 'prod')
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19
permissions:
contents: read
id-token: write

View file

@ -10,4 +10,4 @@ permissions:
jobs:
label:
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19

View file

@ -12,10 +12,6 @@ Use one of: `feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `rele
- **Body sections** (exactly, in order): `Summary`, `Validation`, `Tests`, `Notes`. Use "None." under Notes when empty.
- State verifiable facts only. Do not justify changes by citing the handbook. No AI-attribution footers.
## Security and Cross-Review
- Sensitive surfaces (payment flows, authentication, secrets handling, untrusted input) require security review.
- IAM role, policy, or resource-permission changes require cross-family review. Lambda handler signature changes alone do not.
## CI and Workflow References
- CI must pass before merge.
- Org-level reusable workflow refs must be pinned to a full commit SHA with a `# vX.Y.Z` comment.

View file

@ -542,6 +542,9 @@ components:
targetExtension:
type: string
minLength: 1
note:
type: string
description: Optional. Stored after trim, and the trimmed value must be 500 characters or fewer.
AdminOverrideBody:
type: object
@ -698,6 +701,9 @@ components:
type: string
incoming:
type: boolean
note:
type: string
maxLength: 500
PendingPickup:
type: object

8
package-lock.json generated
View file

@ -8,13 +8,13 @@
"name": "afterhours-shift-manager",
"version": "1.0.0",
"devDependencies": {
"@redocly/cli": "2.53.3"
"@redocly/cli": "2.54.2"
}
},
"node_modules/@redocly/cli": {
"version": "2.53.3",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.53.3.tgz",
"integrity": "sha512-hzNAWzHCOZ05vwRx0ehTxNeJaxxizjGV505eKtfs9MR/8ieD/8lYhAK4GF5FaqRRwOEpLO0PvzQwBSKYXmxhRA==",
"version": "2.54.2",
"resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.54.2.tgz",
"integrity": "sha512-YQ53kSQV/zpYSdY3WiQvf7JxuVLD8jTEX37YOY6MS+G3tyHDCeONpUkAt6qr9n2/nmGm6m+A+PB/mGFvhkt1uQ==",
"dev": true,
"license": "MIT",
"bin": {

View file

@ -6,6 +6,6 @@
"openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml"
},
"devDependencies": {
"@redocly/cli": "2.53.3"
"@redocly/cli": "2.54.2"
}
}

View file

@ -38,6 +38,10 @@ def _deactivate(schedule: ShiftSchedule, date: str) -> dict:
def handler(event, context):
if os.environ.get("STAGE", "prod") != "prod":
logger.info("Skipping holiday router because STAGE is not prod")
return {"skipped": "non_prod"}
action = event.get("action")
date = event.get("date")
logger.info("Holiday router invoked: action=%s date=%s", action, date)

View file

@ -27,6 +27,10 @@ EASTERN = ZoneInfo("America/New_York")
def handler(event, context):
if os.environ.get("STAGE", "prod") != "prod":
logger.info("Skipping 3CX queue scheduler because STAGE is not prod")
return {"skipped": "non_prod"}
now = datetime.now(EASTERN)
current_hour = now.hour
day_name = now.strftime("%A")

View file

@ -24,6 +24,10 @@ EXCLUDE_NAMES = {"Voicemail", "IVR", "Fax"}
def handler(event, context):
if os.environ.get("STAGE", "prod") != "prod":
logger.info("Skipping roster sync because STAGE is not prod")
return {"skipped": "non_prod"}
now = datetime.now(EASTERN)
# DST guard — two EventBridge rules fire, only one is at 6am ET

View file

@ -67,6 +67,8 @@ def create_app() -> Flask:
@app.route("/slack/events", methods=["POST"])
def slack_events():
if os.environ.get("STAGE", "prod") != "prod":
return jsonify({"error": "slack_disabled"}), 404
return _get_slack_handler().handle(request)
@app.route("/api/shifts", methods=["GET", "POST", "DELETE", "OPTIONS"])

View file

@ -254,8 +254,15 @@ def build_shift_change_message(
return [{"type": "section", "text": {"type": "mrkdwn", "text": text}}]
def _mrkdwn_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def build_swap_request_blocks(
requester_slack: str, date_str: str, shift_type: str = "night"
requester_slack: str,
date_str: str,
shift_type: str = "night",
note: str | None = None,
) -> list[dict]:
"""Build the interactive Accept / Decline message DMed to a swap target."""
dt = datetime.strptime(date_str, "%Y-%m-%d")
@ -266,15 +273,18 @@ def build_swap_request_blocks(
else ""
)
action_suffix = "_day" if shift_type == "day" else ""
text = (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
)
if note:
text += f"\n\nNote: {_mrkdwn_text(note)}"
return [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"<@{requester_slack}> wants you to cover the "
f"*{day_label}*{type_label} shift. Accept to take it on."
),
"text": text,
},
},
{

View file

@ -0,0 +1,14 @@
"""External side effects. Only production may call Slack or 3CX.
A missing STAGE is treated as prod so a task that lost its environment
variable does not silently drop production notifications. Dev and any
other named stage skip Slack and 3CX entirely.
"""
from __future__ import annotations
import os
def prod_side_effects_enabled() -> bool:
return os.environ.get("STAGE", "prod") == "prod"

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import os
from shared.effects import prod_side_effects_enabled
from shared.schedule import FALLBACK_EXTENSION, ShiftSchedule
from shared.secrets import get_secret
from shared.three_cx_client import ThreeCXClient, oauth_client
@ -28,6 +29,9 @@ def holiday_extensions(holiday: dict) -> list[str]:
def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
if not prod_side_effects_enabled():
logger.info("Skipping holiday activate because STAGE is not prod")
return {"action": "activate", "date": date, "skipped": "non_prod"}
holiday = schedule.get_holiday(date)
if holiday is None:
logger.info("No holiday record for %s — nothing to activate", date)
@ -77,6 +81,9 @@ def activate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
def deactivate(schedule: ShiftSchedule, date: str, client_factory=None) -> dict:
if not prod_side_effects_enabled():
logger.info("Skipping holiday deactivate because STAGE is not prod")
return {"action": "deactivate", "date": date, "skipped": "non_prod"}
holiday = schedule.get_holiday(date)
if holiday is None:
logger.info("No holiday record for %s — nothing to deactivate", date)

View file

@ -100,6 +100,7 @@ def dispatch(
body.get("date", ""),
body.get("targetExtension", ""),
body.get("shiftType"),
note=body.get("note"),
)
if (
method == "POST"

View file

@ -160,9 +160,25 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
return payload
NOTE_MAX = 500
def _clean_swap_note(note: str | None) -> str | None:
if note is None:
return None
if not isinstance(note, str):
raise ActionError(400, "INVALID_NOTE", "Note must be text.")
cleaned = note.strip()
if not cleaned:
return None
if len(cleaned) > NOTE_MAX:
raise ActionError(400, "NOTE_TOO_LONG", "Note must be 500 characters or fewer.")
return cleaned
def _swap_payload(item: dict, my_ext: str) -> dict:
date_str, shift_type = _sk_to_date_shift(item.get("SK", ""))
return {
payload = {
"date": date_str,
"shiftType": item.get("shift_type") or shift_type,
"requesterExt": item.get("requester_ext", ""),
@ -171,6 +187,10 @@ def _swap_payload(item: dict, my_ext: str) -> dict:
"targetName": item.get("target_name", ""),
"incoming": item.get("target_ext") == my_ext,
}
note = item.get("note")
if isinstance(note, str) and note.strip():
payload["note"] = note.strip()
return payload
def _pickup_payload(item: dict) -> dict:
@ -419,6 +439,7 @@ def swap(
date_str: str,
target_extension: str,
shift_type: str | None,
note: str | None = None,
) -> dict:
date = _parse_date(date_str)
date_str = date.strftime(DATE_FMT)
@ -454,8 +475,11 @@ def swap(
raise ActionError(400, "UNKNOWN_TARGET", "That extension is not on the roster.")
if target["extension"] == employee["extension"]:
raise ActionError(400, "SELF_SWAP", "That shift is already yours.")
cleaned_note = _clean_swap_note(note)
expires_at = int(shift_start(date_str, resolved).timestamp())
schedule.create_pending_swap(date_str, resolved, employee, target, expires_at)
schedule.create_pending_swap(
date_str, resolved, employee, target, expires_at, note=cleaned_note
)
token = effects.slack_token()
if target.get("slack_user_id"):
effects.dm_swap_request(
@ -465,6 +489,7 @@ def swap(
date_str,
resolved,
employee["name"],
note=cleaned_note,
)
return {
"ok": True,

View file

@ -261,15 +261,16 @@ class ShiftSchedule:
requester: dict,
target: dict,
expires_at: int,
note: str | None = None,
) -> None:
"""Create (or supersede) a pending swap request for a shift.
One swap per shift (unique SK), so a new request overwrites any prior
pending one. ``expires_at`` is an epoch timestamp used for DynamoDB TTL.
``note`` is omitted when empty so Slack ``/oncall swap`` stays unchanged.
"""
sk = f"{date_str}-DAY" if shift_type == "day" else date_str
self.table.put_item(
Item={
item = {
"PK": "SWAP",
"SK": sk,
"shift_type": shift_type,
@ -283,7 +284,9 @@ class ShiftSchedule:
"created_at": datetime.now(EASTERN).isoformat(),
"expires_at": expires_at,
}
)
if note:
item["note"] = note
self.table.put_item(Item=item)
def get_swap(self, date_str: str, shift_type: str = "night") -> dict | None:
sk = f"{date_str}-DAY" if shift_type == "day" else date_str

View file

@ -15,9 +15,11 @@ from shared.blocks import (
build_holiday_added_blocks,
build_pickup_request_blocks,
build_shift_change_message,
_mrkdwn_text,
build_swap_request_blocks,
build_week_schedule,
)
from shared.effects import prod_side_effects_enabled
from shared.ring_scheduler import update_queue_routing
from shared.schedule import FALLBACK_EXTENSION, week_start
from shared.secrets import get_secret
@ -30,6 +32,8 @@ SLACK_API = "https://slack.com/api"
def slack_token() -> str | None:
if not prod_side_effects_enabled():
return None
secret_id = os.environ.get("SLACK_BOT_TOKEN_SECRET")
if not secret_id:
return None
@ -41,6 +45,9 @@ def slack_token() -> str | None:
def slack_call(method: str, token: str, **payload) -> bool:
if not prod_side_effects_enabled():
logger.info("Skipping Slack %s because STAGE is not prod", method)
return False
try:
response = requests.post(
f"{SLACK_API}/{method}",
@ -62,6 +69,9 @@ def slack_call(method: str, token: str, **payload) -> bool:
def update_3cx_routing(extension: str) -> None:
if not prod_side_effects_enabled():
logger.info("Skipping 3CX routing because STAGE is not prod")
return
queue_number = os.environ.get("QUEUE_NUMBER")
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not queue_number or not secret_prefix:
@ -88,6 +98,8 @@ def maybe_repoint_today(date_str: str, shift_type: str, extension: str) -> bool:
def make_3cx_client() -> ThreeCXClient | None:
"""Return the process OAuth client, refreshing it when the token or secret changed."""
if not prod_side_effects_enabled():
return None
secret_prefix = os.environ.get("TCX_SECRET_PREFIX")
if not secret_prefix:
logger.warning("3CX env vars not set — skipping 3CX call")
@ -272,15 +284,19 @@ def dm_swap_request(
date_str: str,
shift_type: str,
requester_name: str,
note: str | None = None,
) -> bool:
if not token or not target_slack:
return False
text = f"{requester_name} wants to swap you the {date_str} shift"
if note:
text += f"\nNote: {_mrkdwn_text(note)}"
return slack_call(
"chat.postMessage",
token,
channel=target_slack,
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type),
text=f"{requester_name} wants to swap you the {date_str} shift",
blocks=build_swap_request_blocks(requester_slack, date_str, shift_type, note),
text=text,
)

View file

@ -289,6 +289,10 @@ def _send_checkcomponents(pay_record: dict) -> bool:
def handler(event, context):
if os.environ.get("STAGE", "prod") != "prod":
logger.info("Skipping weekly post because STAGE is not prod")
return {"skipped": "non_prod"}
now = datetime.now(EASTERN)
# DST guard — same pattern as the 3CX scheduler

View file

@ -10,6 +10,16 @@ check "correct_account" {
}
}
check "dev_has_no_external_side_effects" {
assert {
condition = local.is_prod || alltrue([
for name in ["SHIFT_CHANNEL", "QUEUE_NUMBER", "PAY_REPORT_USER", "TCX_SECRET_PREFIX"] :
one([for env in local.api_environment : env.value if env.name == name]) == ""
])
error_message = "Non-prod must leave SHIFT_CHANNEL, QUEUE_NUMBER, PAY_REPORT_USER, and TCX_SECRET_PREFIX empty so the task cannot post to Slack or move the production phone queue."
}
}
check "dev_has_no_paychex" {
assert {
condition = local.is_prod || var.checkcomponents_queue_url == ""

View file

@ -182,9 +182,9 @@ locals {
{ name = "SHIFT_TABLE", value = aws_dynamodb_table.shifts.name },
{ name = "SLACK_BOT_TOKEN_SECRET", value = "afterhours-shift-manager/slack-bot-token" },
{ name = "SLACK_SIGNING_SECRET", value = "afterhours-shift-manager/slack-signing-secret" },
{ name = "SHIFT_CHANNEL", value = var.shift_channel },
{ name = "TCX_SECRET_PREFIX", value = "afterhours-shift-manager/3cx-" },
{ name = "QUEUE_NUMBER", value = var.queue_number },
{ name = "SHIFT_CHANNEL", value = local.is_prod ? var.shift_channel : "" },
{ name = "TCX_SECRET_PREFIX", value = local.is_prod ? "afterhours-shift-manager/3cx-" : "" },
{ name = "QUEUE_NUMBER", value = local.is_prod ? var.queue_number : "" },
{ name = "TZ", value = var.timezone },
{ name = "HOLIDAY_SCHEDULER_ROLE_ARN", value = local.holiday_scheduler_role_arn },
{ name = "HOLIDAY_SCHEDULE_GROUP", value = "default" },
@ -195,7 +195,7 @@ locals {
var.portal_cognito_issuer != "" && var.portal_cognito_audience != "" ? [{ issuer = var.portal_cognito_issuer, audience = var.portal_cognito_audience }] : [],
var.portal_cognito_extra_trust,
)) },
{ name = "PAY_REPORT_USER", value = var.pay_report_user },
{ name = "PAY_REPORT_USER", value = local.is_prod ? var.pay_report_user : "" },
{ name = "CHECKCOMPONENTS_QUEUE_URL", value = var.checkcomponents_queue_url },
{ name = "ROSTER_API_TOKEN_SECRET", value = "afterhours-shift-manager/roster-api-token" },
{ name = "SYNC_GROUP", value = "DEFAULT" },

View file

@ -101,7 +101,7 @@ def test_ecs_task_boundary_uses_static_arns():
def test_deploy_api_workflow_exists():
deploy_api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text()
pin = "cd-hcp-fargate.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16"
pin = "cd-hcp-fargate.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19"
assert deploy_api.count(pin) == 2
assert "ssm-prefix: /afterhours-shift-manager/deploy" in deploy_api
assert "docker-platform: linux/arm64" in deploy_api
@ -119,9 +119,9 @@ def test_in_repo_hcptf_roles():
def test_ci_runs_pytest_and_terraform_validate():
assert "ci-python-sam" not in CI
assert "ci-python-app.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-terraform.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-autofix.yaml@2e2b3a282fbd148352a2b7433b58668a47e6bfeb # v1.0.16" in CI
assert "ci-python-app.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
assert "ci-terraform.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
assert "ci-autofix.yaml@0a1010e63248c9ca9f042c870eb2c579ba6b9455 # v1.0.19" in CI
assert "name: ci-complete" in CI
assert "pytest" in CI
assert "terraform fmt -check" not in CI

View file

@ -259,6 +259,37 @@ class TestBuildSwapRequestBlocks:
assert elements[0]["style"] == "primary" # Accept
assert elements[1]["style"] == "danger" # Decline
def test_note_is_appended_without_changing_actions(self):
blocks = build_swap_request_blocks(
"U_REQ", "2026-06-03", "night", note="Family <commitment> & more"
)
assert "Note: Family &lt;commitment&gt; &amp; more" in blocks[0]["text"]["text"]
ids = self._action_ids(blocks)
assert ids == ["swap_accept_2026-06-03", "swap_decline_2026-06-03"]
def test_dm_swap_fallback_escapes_the_note(monkeypatch):
from shared import side_effects as effects
captured = {}
def fake_slack_call(_method, _token, **kwargs):
captured.update(kwargs)
return True
monkeypatch.setattr(effects, "slack_call", fake_slack_call)
assert effects.dm_swap_request(
"tok",
"U_REQ",
"U_TGT",
"2026-06-03",
"night",
"Alice",
note="ping <@U_ADMIN>",
)
assert "Note: ping &lt;@U_ADMIN&gt;" in captured["text"]
assert "<@U_ADMIN>" not in captured["text"]
class TestBuildSwapResolvedBlocks:
def test_renders_text_no_buttons(self):

View file

@ -0,0 +1,54 @@
"""Non-prod must not call Slack or 3CX."""
from unittest.mock import MagicMock
from shared.effects import prod_side_effects_enabled
from shared.holiday_flow import activate
from shared.side_effects import slack_call, slack_token, update_3cx_routing
def test_missing_stage_keeps_prod_effects(monkeypatch):
monkeypatch.delenv("STAGE", raising=False)
assert prod_side_effects_enabled() is True
def test_dev_stage_disables_effects(monkeypatch):
monkeypatch.setenv("STAGE", "dev")
assert prod_side_effects_enabled() is False
def test_dev_slack_token_does_not_read_secrets(monkeypatch):
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv(
"SLACK_BOT_TOKEN_SECRET", "afterhours-shift-manager/slack-bot-token"
)
read = MagicMock(side_effect=AssertionError("secret read"))
monkeypatch.setattr("shared.side_effects.get_secret", read)
assert slack_token() is None
read.assert_not_called()
def test_dev_slack_call_does_not_post(monkeypatch):
monkeypatch.setenv("STAGE", "dev")
post = MagicMock(side_effect=AssertionError("slack post"))
monkeypatch.setattr("shared.side_effects.requests.post", post)
assert slack_call("chat.postMessage", "xoxb-token", channel="C0APATP612N") is False
post.assert_not_called()
def test_dev_skips_3cx_even_when_queue_is_configured(monkeypatch):
monkeypatch.setenv("STAGE", "dev")
monkeypatch.setenv("QUEUE_NUMBER", "801")
monkeypatch.setenv("TCX_SECRET_PREFIX", "afterhours-shift-manager/3cx-")
route = MagicMock(side_effect=AssertionError("3cx"))
monkeypatch.setattr("shared.side_effects.update_queue_routing", route)
update_3cx_routing("101")
route.assert_not_called()
def test_dev_holiday_activate_does_not_build_a_client(monkeypatch):
monkeypatch.setenv("STAGE", "dev")
factory = MagicMock(side_effect=AssertionError("3cx client"))
result = activate(MagicMock(), "2026-07-04", client_factory=factory)
assert result["skipped"] == "non_prod"
factory.assert_not_called()

View file

@ -75,6 +75,73 @@ def test_swap_creates_pending(schedule, seed, quiet_slack):
assert snap["pendingSwaps"][0]["incoming"] is True
mine = snapshot(schedule, employee)
assert mine["pendingSwaps"][0]["incoming"] is False
assert "note" not in pending
assert "note" not in snap["pendingSwaps"][0]
def test_swap_stores_returns_and_dms_note(schedule, seed, quiet_slack, monkeypatch):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
calls = []
def capture(*args, **kwargs):
calls.append((args, kwargs))
return True
monkeypatch.setattr(effects, "dm_swap_request", capture)
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(
schedule,
employee,
"2026-06-12",
"115",
"night",
note=" Family commitment ",
)
pending = schedule.get_swap("2026-06-12", "night")
assert pending["note"] == "Family commitment"
snap = snapshot(schedule, employee)
assert snap["pendingSwaps"][0]["note"] == "Family commitment"
assert calls[0][1]["note"] == "Family commitment"
def test_swap_accepts_note_that_trims_to_500(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(schedule, employee, "2026-06-12", "115", "night", note=f" {'a' * 500} ")
assert schedule.get_swap("2026-06-12", "night")["note"] == "a" * 500
def test_swap_omits_blank_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
swap(schedule, employee, "2026-06-12", "115", "night", note=" ")
pending = schedule.get_swap("2026-06-12", "night")
assert "note" not in pending
snap = snapshot(schedule, employee)
assert "note" not in snap["pendingSwaps"][0]
def test_swap_rejects_long_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note="a" * 501)
assert err.value.code == "NOTE_TOO_LONG"
assert schedule.get_swap("2026-06-12", "night") is None
def test_swap_rejects_non_string_note(schedule, seed, quiet_slack):
employee = _alice(schedule, seed)
seed.override("2026-06-12", "114", "Alice")
with freezegun.freeze_time("2026-06-08 12:00:00-04:00"):
with pytest.raises(ActionError) as err:
swap(schedule, employee, "2026-06-12", "115", "night", note=12)
assert err.value.code == "INVALID_NOTE"
assert schedule.get_swap("2026-06-12", "night") is None
def test_late_pickup_creates_request(schedule, seed, quiet_slack):