fix(portal-api): preserve shift and deployment invariants (DEV-287)

Co-authored-by: adam <adam@seahavenind.com>
This commit is contained in:
Cursor Agent 2026-09-21 00:47:27 +00:00
parent 1ab787308e
commit 7fdad3a6d5
No known key found for this signature in database
4 changed files with 179 additions and 6 deletions

View file

@ -2,11 +2,12 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
from zoneinfo import ZoneInfo 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 ( from shared.shift_clock import (
EASTERN, EASTERN,
holiday_window_active, holiday_window_active,
@ -113,7 +114,7 @@ def snapshot(schedule: ShiftSchedule, employee: dict, week: str = "this") -> dic
date_str = day.strftime(DATE_FMT) date_str = day.strftime(DATE_FMT)
day_name = day.strftime("%A") day_name = day.strftime("%A")
slots = [] 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, "day", my_ext))
slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext)) slots.append(_slot_payload(schedule, date_str, day_name, "night", my_ext))
days.append( days.append(
@ -317,6 +318,8 @@ def drop(schedule: ShiftSchedule, employee: dict, date_str: str, shift_type: str
target = target target = target
else: else:
raise ActionError(400, "INVALID_SHIFT", "Shift type must be day, night, or holiday.") 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: if target not in held:
raise ActionError(409, "NOT_YOURS", "You don't hold that shift.") raise ActionError(409, "NOT_YOURS", "You don't hold that shift.")
elif not held: elif not held:
@ -483,6 +486,16 @@ def respond_swap(
if holiday_window_active(date_str): if holiday_window_active(date_str):
effects.set_holiday_queue_agents(schedule, date_str) effects.set_holiday_queue_agents(schedule, date_str)
else: 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( moved = schedule.reassign_if_held_by(
date_str, date_str,
swap_row["requester_ext"], swap_row["requester_ext"],
@ -538,8 +551,13 @@ def admin_open(schedule, employee, date_str, shift_type) -> dict:
date_str = date.strftime(DATE_FMT) date_str = date.strftime(DATE_FMT)
shift_type = _shift_type(shift_type) shift_type = _shift_type(shift_type)
schedule.mark_open(date_str, 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()) 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: 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) date_str = date.strftime(DATE_FMT)
shift_type = _shift_type(shift_type) shift_type = _shift_type(shift_type)
schedule.remove_override(date_str, 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()) 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: 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() name = (label or "").strip()
if not name: if not name:
raise ActionError(400, "INVALID_LABEL", "A holiday label is required.") 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() token = effects.slack_token()
names = effects.create_holiday_schedules(date_str) names = effects.create_holiday_schedules(date_str)
created = schedule.create_holiday( created = schedule.create_holiday(
@ -574,7 +610,7 @@ def admin_holiday_add(schedule, employee, date_str, slots, label, multiplier) ->
slots=slot_count, slots=slot_count,
label=name, label=name,
created_by=employee.get("slack_user_id") or employee.get("email") or "", created_by=employee.get("slack_user_id") or employee.get("email") or "",
multiplier=multiplier, multiplier=multiplier_value,
schedule_names=names, schedule_names=names,
) )
if not created: if not created:

View file

@ -59,6 +59,12 @@ variable "checkcomponents_queue_url" {
default = "https://sqs.us-east-1.amazonaws.com/011934824531/paychex-checkcomponents" 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" { variable "portal_cognito_issuer" {
description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth." description = "Trusted portal Cognito user-pool issuer for ID-token verification. Empty disables portal auth."
type = string type = string

View file

@ -9,6 +9,7 @@ HCP_IAM = (TERRAFORM / "hcp_iam.tf").read_text()
DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text() DEPLOY = (ROOT / ".github" / "workflows" / "deploy.yaml").read_text()
CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text() CI = (ROOT / ".github" / "workflows" / "ci.yaml").read_text()
LOCALS = (TERRAFORM / "locals.tf").read_text() LOCALS = (TERRAFORM / "locals.tf").read_text()
VARIABLES = (TERRAFORM / "variables.tf").read_text()
def test_sam_template_removed(): def test_sam_template_removed():
@ -68,6 +69,13 @@ def test_ci_runs_pytest_and_terraform_validate():
assert "terraform validate" in CI 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(): def test_eight_functions_named():
for name in ( for name in (
"afterhours-shift-manager", "afterhours-shift-manager",

View file

@ -5,7 +5,17 @@ from zoneinfo import ZoneInfo
import freezegun import freezegun
import pytest 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 from shared import side_effects as effects
ET = ZoneInfo("America/New_York") ET = ZoneInfo("America/New_York")
@ -30,6 +40,12 @@ def _alice(schedule, seed):
return schedule.get_employee_by_extension("114") 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): def test_pick_open_shift(schedule, seed, quiet_slack):
employee = _alice(schedule, seed) employee = _alice(schedule, seed)
seed.open_shift("2026-06-10") 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 assert result["latePickup"] is True
req = schedule.get_pickup_request("2026-06-10", "night", "114") req = schedule.get_pickup_request("2026-06-10", "night", "114")
assert req["status"] == "pending" 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