mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 08:53:13 +00:00
fix: Email order filenames, SSM param TTL, DST-safe reopen_at
- Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo) - shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes - form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta) - Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case Co-authored-by: Adam Moussa <amoussa1229@users.noreply.github.com>
This commit is contained in:
parent
fa9ac6974e
commit
6bc5ccaedd
5 changed files with 127 additions and 32 deletions
|
|
@ -5,7 +5,8 @@ import sys
|
|||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
import datetime as _dt
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
|
@ -22,6 +23,12 @@ if not logger.handlers:
|
|||
EASTERN = ZoneInfo("America/New_York")
|
||||
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
||||
|
||||
|
||||
def _eastern_now() -> _dt.datetime:
|
||||
"""Wall-clock 'now' in Eastern time (patch target for form-status tests)."""
|
||||
return _dt.datetime.now(EASTERN)
|
||||
|
||||
|
||||
_api_key = None
|
||||
_settings = None
|
||||
_settings_ts = 0.0
|
||||
|
|
@ -128,14 +135,18 @@ def handle_form_status(event):
|
|||
status = get_form_status(week)
|
||||
result = {"week": week, "status": status}
|
||||
if status == "closed":
|
||||
# Compute next Monday 8:00 AM Eastern, accounting for DST
|
||||
now_et = datetime.now(EASTERN)
|
||||
days_until_monday = (7 - now_et.weekday()) % 7
|
||||
# Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at
|
||||
# stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours).
|
||||
now_et = _eastern_now()
|
||||
today = now_et.date()
|
||||
weekday = today.weekday() # Monday=0 ... Sunday=6
|
||||
days_until_monday = (7 - weekday) % 7
|
||||
if days_until_monday == 0 and now_et.hour >= 8:
|
||||
# If today is Monday past 8am, next Monday is 7 days away
|
||||
days_until_monday = 7
|
||||
next_monday = (now_et + timedelta(days=days_until_monday)).replace(
|
||||
hour=8, minute=0, second=0, microsecond=0
|
||||
reopen_date = today + timedelta(days=days_until_monday)
|
||||
next_monday = _dt.datetime.combine(
|
||||
reopen_date, _dt.time(8, 0), tzinfo=EASTERN
|
||||
)
|
||||
result["reopen_at"] = int(next_monday.timestamp())
|
||||
return response(200, result)
|
||||
|
|
@ -248,7 +259,7 @@ def handle_submit(event):
|
|||
order_data = {
|
||||
"employee_name": name,
|
||||
"employee_email": email,
|
||||
"submitted_at": datetime.now(EASTERN).isoformat(),
|
||||
"submitted_at": _eastern_now().isoformat(),
|
||||
"items": filtered_items,
|
||||
"total": total,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,13 +154,10 @@ def submit_order():
|
|||
week_dir = ORDERS_DIR / week
|
||||
week_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
safe_name = (
|
||||
"".join(c if c.isalnum() or c in "-_ " else "" for c in name)
|
||||
.strip()
|
||||
.replace(" ", "-")
|
||||
.lower()
|
||||
)
|
||||
order_file = week_dir / f"{safe_name}.json"
|
||||
# Match Lambda: one order file per employee email (not display name).
|
||||
slug = email.strip().lower()
|
||||
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
||||
order_file = week_dir / f"{slug_safe}.json"
|
||||
|
||||
total = float(
|
||||
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import time
|
||||
|
||||
import boto3
|
||||
|
||||
_cache = {}
|
||||
_secret_cache: dict[str, str] = {}
|
||||
_parameter_cache: dict[str, tuple[str, float]] = {}
|
||||
_sm = boto3.client("secretsmanager")
|
||||
_ssm = boto3.client("ssm")
|
||||
|
||||
# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh
|
||||
# on the same cadence as their own caches. Secrets stay cached for the process lifetime.
|
||||
PARAM_CACHE_TTL_SECONDS = 300.0
|
||||
|
||||
|
||||
def get_secret(secret_id: str) -> str:
|
||||
if secret_id not in _cache:
|
||||
if secret_id not in _secret_cache:
|
||||
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||
_cache[secret_id] = resp["SecretString"]
|
||||
return _cache[secret_id]
|
||||
_secret_cache[secret_id] = resp["SecretString"]
|
||||
return _secret_cache[secret_id]
|
||||
|
||||
|
||||
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||
if name not in _cache:
|
||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
||||
_cache[name] = resp["Parameter"]["Value"]
|
||||
return _cache[name]
|
||||
now = time.monotonic()
|
||||
entry = _parameter_cache.get(name)
|
||||
if entry is not None:
|
||||
value, cached_at = entry
|
||||
if now - cached_at < PARAM_CACHE_TTL_SECONDS:
|
||||
return value
|
||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
||||
value = resp["Parameter"]["Value"]
|
||||
_parameter_cache[name] = (value, now)
|
||||
return value
|
||||
|
|
|
|||
50
tests/test_secrets.py
Normal file
50
tests/test_secrets.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Tests for shared.secrets caching."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def test_get_parameter_refetches_after_ttl():
|
||||
"""SSM parameter values expire so callers can observe rotations."""
|
||||
from shared import secrets
|
||||
|
||||
secrets._secret_cache.clear()
|
||||
secrets._parameter_cache.clear()
|
||||
|
||||
mock_ssm = MagicMock()
|
||||
mock_ssm.get_parameter.side_effect = [
|
||||
{"Parameter": {"Value": "first"}},
|
||||
{"Parameter": {"Value": "second"}},
|
||||
]
|
||||
|
||||
with patch.object(secrets, "_ssm", mock_ssm):
|
||||
with patch.object(secrets, "PARAM_CACHE_TTL_SECONDS", 10.0):
|
||||
with patch(
|
||||
"shared.secrets.time.monotonic",
|
||||
side_effect=[0.0, 5.0, 15.0],
|
||||
):
|
||||
assert secrets.get_parameter("/test/param") == "first"
|
||||
assert secrets.get_parameter("/test/param") == "first"
|
||||
assert secrets.get_parameter("/test/param") == "second"
|
||||
|
||||
assert mock_ssm.get_parameter.call_count == 2
|
||||
|
||||
|
||||
def test_get_secret_stays_cached():
|
||||
"""Secrets Manager values remain cached (no TTL)."""
|
||||
from shared import secrets
|
||||
|
||||
secrets._secret_cache.clear()
|
||||
secrets._parameter_cache.clear()
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.get_secret_value.side_effect = [
|
||||
{"SecretString": "a"},
|
||||
{"SecretString": "b"},
|
||||
]
|
||||
|
||||
with patch.object(secrets, "_sm", mock_sm):
|
||||
with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]):
|
||||
assert secrets.get_secret("arn:aws:secret") == "a"
|
||||
assert secrets.get_secret("arn:aws:secret") == "a"
|
||||
|
||||
assert mock_sm.get_secret_value.call_count == 1
|
||||
|
|
@ -135,10 +135,11 @@ def _reset_handler_caches():
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_shared_caches():
|
||||
"""Reset shared.secrets cache before each test."""
|
||||
"""Reset shared.secrets caches before each test."""
|
||||
from shared import secrets
|
||||
|
||||
secrets._cache = {}
|
||||
secrets._secret_cache.clear()
|
||||
secrets._parameter_cache.clear()
|
||||
yield
|
||||
|
||||
|
||||
|
|
@ -908,10 +909,7 @@ def test_form_status_closed_reopen_at(mock_week, mock_status):
|
|||
|
||||
# Freeze "now" to Thursday 2026-05-14 at 10:00 AM Eastern
|
||||
thursday = datetime(2026, 5, 14, 10, 0, 0, tzinfo=EASTERN)
|
||||
with patch("submit_order_handler.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = thursday
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
|
||||
with patch("submit_order_handler._eastern_now", return_value=thursday):
|
||||
event = _make_event(
|
||||
method="GET",
|
||||
path="/form-status/2026-W20",
|
||||
|
|
@ -943,10 +941,7 @@ def test_form_status_monday_before_8am(mock_week, mock_status):
|
|||
|
||||
# Monday 2026-05-18 at 6:30 AM Eastern (before 8am cutoff)
|
||||
monday_early = datetime(2026, 5, 18, 6, 30, 0, tzinfo=EASTERN)
|
||||
with patch("submit_order_handler.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = monday_early
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
|
||||
with patch("submit_order_handler._eastern_now", return_value=monday_early):
|
||||
event = _make_event(
|
||||
method="GET",
|
||||
path="/form-status/2026-W20",
|
||||
|
|
@ -968,6 +963,35 @@ def test_form_status_monday_before_8am(mock_week, mock_status):
|
|||
)
|
||||
|
||||
|
||||
@patch("submit_order_handler.get_form_status", return_value="closed")
|
||||
@patch("submit_order_handler.current_week", return_value="2025-W45")
|
||||
def test_form_status_closed_saturday_before_dst_end(mock_week, mock_status):
|
||||
"""Saturday before fall-back: reopen_at is the *calendar* Monday at 8am ET.
|
||||
|
||||
Using timedelta(days=n) for n weekdays is 24n hours and can land on the wrong
|
||||
local calendar day when a 25-hour Sunday sits in between.
|
||||
"""
|
||||
from submit_order_handler import lambda_handler, EASTERN
|
||||
|
||||
# Nov 1 2025 is Saturday (DST still on until early Nov 2). Next Monday is Nov 3.
|
||||
saturday = datetime(2025, 11, 1, 12, 0, 0, tzinfo=EASTERN)
|
||||
with patch("submit_order_handler._eastern_now", return_value=saturday):
|
||||
event = _make_event(
|
||||
method="GET",
|
||||
path="/form-status/2025-W45",
|
||||
path_parameters={"week": "2025-W45"},
|
||||
)
|
||||
result = lambda_handler(event, None)
|
||||
|
||||
status, body = _parse_response(result)
|
||||
assert status == 200
|
||||
expected_monday = datetime(2025, 11, 3, 8, 0, 0, tzinfo=EASTERN)
|
||||
expected_ts = int(expected_monday.timestamp())
|
||||
assert body["reopen_at"] == expected_ts, (
|
||||
f"reopen_at should be {expected_ts} (Mon 2025-11-03 8am ET), got {body['reopen_at']}"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# VALIDATION (High)
|
||||
# ===========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue