From 7fdad3a6d5ca49eb6a3e6a3e13081ca3f31095e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 00:47:27 +0000 Subject: [PATCH] fix(portal-api): preserve shift and deployment invariants (DEV-287) Co-authored-by: adam --- src/shared/shared/portal_ops.py | 46 ++++++++++-- terraform/variables.tf | 6 ++ tests/infra/test_hcp_contract.py | 8 ++ tests/shared/test_portal_ops.py | 125 ++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 6 deletions(-) diff --git a/src/shared/shared/portal_ops.py b/src/shared/shared/portal_ops.py index 3eba997..df12747 100644 --- a/src/shared/shared/portal_ops.py +++ b/src/shared/shared/portal_ops.py @@ -2,11 +2,12 @@ from __future__ import annotations +import re from datetime import datetime, timedelta from decimal import Decimal from zoneinfo import ZoneInfo -from shared.schedule import WEEKEND_DAYS, ShiftSchedule +from shared.schedule import FALLBACK_EXTENSION, WEEKEND_DAYS, ShiftSchedule from shared.shift_clock import ( EASTERN, holiday_window_active, @@ -113,7 +114,7 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic date_str = day.strftime(DATE_FMT) day_name = day.strftime("%A") slots = [] - if day_name in WEEKEND_DAYS: + if day_name in WEEKEND_DAYS or schedule.get_holiday(date_str) is not None: slots.append(_slot_payload(schedule, date_str, day_name, "day", my_ext)) slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext)) days.append( @@ -317,6 +318,8 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str target = target else: raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.") + if target == "day" and "holiday" in held: + target = "holiday" if target not in held: raise ActionError(409, "NOT_YOURS", "You don't hold that shift.") elif not held: @@ -483,6 +486,16 @@ def respond_swap( if holiday_window_active(date_str): effects.set_holiday_queue_agents(schedule, date_str) else: + current_ext, _name, _source = schedule.resolve_shift( + date_str, date.strftime("%A"), shift_type + ) + if current_ext != swap_row["requester_ext"]: + schedule.clear_swap(date_str, shift_type) + raise ActionError( + 409, + "CONFLICT", + "The shift is no longer assigned to the person who requested the swap.", + ) moved = schedule.reassign_if_held_by( date_str, swap_row["requester_ext"], @@ -538,8 +551,13 @@ def admin_open(schedule, employee, date_str, shift_type) -> dict: date_str = date.strftime(DATE_FMT) shift_type = _shift_type(shift_type) schedule.mark_open(date_str, shift_type) + repointed = effects.maybe_repoint_today(date_str, shift_type, FALLBACK_EXTENSION) effects.refresh_schedule_post(schedule, effects.slack_token()) - return {"ok": True, "message": "Shift marked open."} + return { + "ok": True, + "repointed": repointed, + "message": "Shift marked open.", + } def admin_clear(schedule, employee, date_str, shift_type) -> dict: @@ -548,8 +566,16 @@ def admin_clear(schedule, employee, date_str, shift_type) -> dict: date_str = date.strftime(DATE_FMT) shift_type = _shift_type(shift_type) schedule.remove_override(date_str, shift_type) + resolved_ext, _name, _source = schedule.resolve_shift( + date_str, date.strftime("%A"), shift_type + ) + repointed = effects.maybe_repoint_today(date_str, shift_type, resolved_ext) effects.refresh_schedule_post(schedule, effects.slack_token()) - return {"ok": True, "message": "Override cleared."} + return { + "ok": True, + "repointed": repointed, + "message": "Override cleared.", + } def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> dict: @@ -567,6 +593,16 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> name = (label or "").strip() if not name: raise ActionError(400, "INVALID_LABEL", "A holiday label is required.") + multiplier_value = None + if multiplier is not None and str(multiplier).strip(): + match = re.fullmatch(r"x?([0-9]+(?:\.[0-9]+)?)", str(multiplier).strip(), re.I) + if not match: + raise ActionError( + 400, + "INVALID_MULTIPLIER", + "Multiplier must be a number like 2 or 1.5.", + ) + multiplier_value = Decimal(match.group(1)) token = effects.slack_token() names = effects.create_holiday_schedules(date_str) created = schedule.create_holiday( @@ -574,7 +610,7 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) -> slots=slot_count, label=name, created_by=employee.get("slack_user_id") or employee.get("email") or "", - multiplier=multiplier, + multiplier=multiplier_value, schedule_names=names, ) if not created: diff --git a/terraform/variables.tf b/terraform/variables.tf index 252627c..5cda090 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -59,6 +59,12 @@ variable "checkcomponents_queue_url" { default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents" } +variable "checkcomponents_queue_arn" { + description = "paychex-checkcomponents SQS ARN for WeeklyPost SendMessage." + type = string + default = "arn:aws:sqs:us-east-1:011934824531:paychex-checkcomponents" +} + variable "portal_cognito_issuer" { description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth." type = string diff --git a/tests/infra/test_hcp_contract.py b/tests/infra/test_hcp_contract.py index c629e4a..9198988 100644 --- a/tests/infra/test_hcp_contract.py +++ b/tests/infra/test_hcp_contract.py @@ -9,6 +9,7 @@ HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text() DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text() CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text() LOCALS = (TERRAFORM / "locals.tf").read_text() +VARIABLES = (TERRAFORM / "variables.tf").read_text() def test_sam_template_removed(): @@ -68,6 +69,13 @@ def test_ci_runs_pytest_and_terraform_validate(): assert "terraform validate" in CI +def test_checkcomponents_queue_arn_variable_matches_iam_references(): + assert 'variable "checkcomponents_queue_arn"' in VARIABLES + assert "var.checkcomponents_queue_arn" in LAMBDA_TF + boundary = (TERRAFORM / "lambda_boundary.tf").read_text() + assert "var.checkcomponents_queue_arn" in boundary + + def test_eight_functions_named(): for name in ( "afterhours-shift-manager", diff --git a/tests/shared/test_portal_ops.py b/tests/shared/test_portal_ops.py index bfbe6eb..27966d9 100644 --- a/tests/shared/test_portal_ops.py +++ b/tests/shared/test_portal_ops.py @@ -5,7 +5,17 @@ from zoneinfo import ZoneInfo import freezegun import pytest -from shared.portal_ops import ActionError, drop, pick, snapshot, swap +from shared.portal_ops import ( + ActionError, + admin_clear, + admin_holiday_add, + admin_open, + drop, + pick, + respond_swap, + snapshot, + swap, +) from shared import side_effects as effects ET = ZoneInfo("America/New_York") @@ -30,6 +40,12 @@ def _alice(schedule, seed): return schedule.get_employee_by_extension("114") +def _admin(schedule, seed): + employee = _alice(schedule, seed) + seed.config(admin_users=["U_ALICE"]) + return employee + + def test_pick_open_shift(schedule, seed, quiet_slack): employee = _alice(schedule, seed) seed.open_shift("2026-06-10") @@ -71,3 +87,110 @@ def test_late_pickup_creates_request(schedule, seed, quiet_slack): assert result["latePickup"] is True req = schedule.get_pickup_request("2026-06-10", "night", "114") assert req["status"] == "pending" + + +def test_snapshot_includes_weekday_holiday_day_shift(schedule, seed): + employee = _alice(schedule, seed) + seed.holiday( + "2026-06-10", + assignees={"114": {"name": "Alice", "claimed_at": "now"}}, + ) + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + snap = snapshot(schedule, employee) + wednesday = next(day for day in snap["days"] if day["date"] == "2026-06-10") + assert wednesday["slots"][0]["kind"] == "holiday" + assert wednesday["slots"][0]["shiftType"] == "day" + assert wednesday["slots"][0]["mine"] is True + + +def test_drop_accepts_day_for_holiday_slot(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.holiday( + "2026-06-10", + assignees={"114": {"name": "Alice", "claimed_at": "now"}}, + ) + with freezegun.freeze_time("2026-06-08 06:00:00-04:00"): + result = drop(schedule, employee, "2026-06-10", "day") + assert result["ok"] is True + assert schedule.get_holiday("2026-06-10")["assignees"] == {} + + +def test_swap_accept_rechecks_resolved_holder(schedule, seed, quiet_slack): + employee = _alice(schedule, seed) + seed.roster("116", "Charlie", slack_user_id="U_CHARLIE") + seed.weekly("Friday", "114", "Alice") + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + swap(schedule, employee, "2026-06-12", "115", "night") + seed.weekly("Friday", "116", "Charlie") + with pytest.raises(ActionError) as err: + respond_swap( + schedule, + schedule.get_employee_by_extension("115"), + "2026-06-12", + "night", + True, + ) + assert err.value.code == "CONFLICT" + assert schedule.get_override("2026-06-12") is None + assert schedule.get_swap("2026-06-12", "night") is None + + +def test_admin_open_repoints_active_shift_to_fallback( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + calls = [] + monkeypatch.setattr( + effects, + "maybe_repoint_today", + lambda date, shift_type, extension: calls.append( + (date, shift_type, extension) + ) + or True, + ) + with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): + result = admin_open(schedule, employee, "2026-06-10", "night") + assert result["repointed"] is True + assert calls == [("2026-06-10", "night", "100")] + + +def test_admin_clear_repoints_active_shift_to_resolved_holder( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + seed.weekly("Wednesday", "115", "Bob") + seed.override("2026-06-10", "114", "Alice") + calls = [] + monkeypatch.setattr( + effects, + "maybe_repoint_today", + lambda date, shift_type, extension: calls.append( + (date, shift_type, extension) + ) + or True, + ) + with freezegun.freeze_time("2026-06-10 20:00:00-04:00"): + result = admin_clear(schedule, employee, "2026-06-10", "night") + assert result["repointed"] is True + assert calls == [("2026-06-10", "night", "115")] + + +def test_invalid_holiday_multiplier_rejected_before_schedules( + schedule, seed, quiet_slack, monkeypatch +): + employee = _admin(schedule, seed) + monkeypatch.setattr( + effects, "create_holiday_schedules", lambda _date: pytest.fail("called") + ) + with freezegun.freeze_time("2026-06-08 12:00:00-04:00"): + with pytest.raises(ActionError) as err: + admin_holiday_add( + schedule, + employee, + "2026-12-25", + 2, + "Christmas", + "not-a-number", + ) + assert err.value.code == "INVALID_MULTIPLIER" + assert schedule.get_holiday("2026-12-25") is None