mirror of
https://github.com/Sea-Haven-Industries/afterhours-shift-manager.git
synced 2026-09-30 17:13:12 +00:00
fix(portal-api): preserve shift and deployment invariants (DEV-287)
Co-authored-by: adam <adam@seahavenind.com>
This commit is contained in:
parent
1ab787308e
commit
7fdad3a6d5
4 changed files with 179 additions and 6 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue