diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbf3f84..506614f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Check template JavaScript run: npm run check:templates + - name: Lint OpenAPI + run: npm run openapi:lint + test: runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.redocly.yaml b/.redocly.yaml new file mode 100644 index 0000000..473f95c --- /dev/null +++ b/.redocly.yaml @@ -0,0 +1,32 @@ +# Same Redocly recommended ruleset as internal-portal (DEV-223 / DEV-289). +# Recommended operation-2xx-response does not count 302. Login-style redirects +# succeed with 302, so that rule is replaced by operation-2xx-or-3xx-response +# at error. operation-4xx-response is promoted to error so missing 4xx fails CI. +extends: + - recommended + +rules: + operation-2xx-response: off + operation-4xx-response: error + rule/operation-2xx-or-3xx-response: + subject: + type: Responses + message: Operation must define a 2XX or 3XX response. + severity: error + assertions: + requireAny: + - "200" + - "201" + - "202" + - "204" + - "301" + - "302" + - "303" + - "307" + - "308" + - "2XX" + - "3XX" + +apis: + meals@v1: + root: openapi.yaml diff --git a/README.md b/README.md index dbdba00..8417a32 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Publish routes are omitted from CloudFront. The generated form uses relative Workspace: `meal-order-manager-prod` / `meal-order-manager-dev` (us-east-1) - **ECS Fargate** — Flask + gunicorn + SQS job consumer. Desired count 2 in prod, 1 in dev. +- **VPC** — Prod attaches to the After Hours VPC (`existing_vpc_id` / `existing_public_subnet_ids` from afterhours-shift-manager outputs). The 10.60 CIDR is unused fallback. +- **HTTP contract** — `openapi.yaml`, linted in CI with `npm run openapi:lint` (Redocly `extends: recommended`, same as internal-portal and afterhours-shift-manager). - **ALB** — origin for CloudFront `/api` behaviors and weekly-menu HMAC publish. Idle timeout 120s. - **ECR** — API image. GitHub Actions `deploy-api.yaml` owns the image; Terraform ignores `container_definitions`. - **S3** — `meal-order-manager-form-*` (static form hosting), `meal-order-manager-reports-*` (CSV reports + weekly summary PDF) @@ -186,9 +188,11 @@ meal-order-manager/ ├── .github/workflows/ │ ├── weekly-menu.yml # Monday cron: scrape + HMAC publish + notify │ ├── deploy-api.yaml # Image CD to Fargate -│ ├── ci.yml # PR checks +│ ├── ci.yml # PR checks (pytest, template JS, OpenAPI lint) │ └── ci-terraform.yaml # terraform fmt / validate ├── terraform/ # HCP Terraform (cluster, ALB, ECR, jobs queue) +├── openapi.yaml # Employee HTTP contract (Redocly recommended) +├── .redocly.yaml ├── Dockerfile ├── src/ │ ├── scraper/ # Playwright menu scraper diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..b7ce5e3 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,665 @@ +openapi: 3.1.0 +info: + title: Meal Order Manager + version: 0.1.0 + description: > + Flask HTTP API on ECS Fargate behind orders.seahaven.com. The internal + portal SPA calls menu, submit, and admin with a Cognito ID token from + GET /api/auth/meals-token. Weekly-menu GitHub Actions uses HMAC publish + routes that CloudFront does not expose. JSON errors are currently + `{ error: string }`. Health matches the portal BFF `{ stage, sha }`. + Lint with the same Redocly `extends: recommended` config as + internal-portal and afterhours-shift-manager. + contact: + name: Sea Haven Engineering + license: + name: Proprietary + identifier: LicenseRef-SeaHaven + +servers: + - url: / + description: orders.seahaven.com CloudFront / local Flask :5050 + +tags: + - name: Runtime + description: Unauthenticated health + - name: Menu + description: Public weekly menu and form status + - name: Orders + description: Employee submit and own-order lookup + - name: Admin + description: Admin order edit and summary PDF + - name: Publish + description: Weekly-menu HMAC publish (not on CloudFront) + +paths: + /api/health: + get: + operationId: getHealth + tags: [Runtime] + summary: Runtime health + security: [] + responses: + "200": + description: Process is up + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + "403": + $ref: "#/components/responses/StringError" + + /api/menu/{week}: + get: + operationId: getMenu + tags: [Menu] + summary: Published menu for a week + security: [] + parameters: + - $ref: "#/components/parameters/MenuWeekPath" + responses: + "200": + description: Menu payload + content: + application/json: + schema: + $ref: "#/components/schemas/MenuPayload" + "400": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + + /api/form-status/{week}: + get: + operationId: getFormStatus + tags: [Menu] + summary: Open or closed for a week + security: [] + parameters: + - $ref: "#/components/parameters/MenuWeekPath" + responses: + "200": + description: Form status + content: + application/json: + schema: + $ref: "#/components/schemas/FormStatus" + "400": + $ref: "#/components/responses/StringError" + + /api/submit-order: + post: + operationId: submitOrder + tags: [Orders] + summary: Place or replace this week's order + security: + - portalCognito: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitBody" + responses: + "200": + description: Order saved + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitResult" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "410": + $ref: "#/components/responses/StringError" + "429": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + + /api/orders/{week}: + get: + operationId: getMyOrder + tags: [Orders] + summary: Caller's order only + security: + - portalCognito: [] + parameters: + - $ref: "#/components/parameters/OrderWeekPath" + responses: + "200": + description: Empty list when the caller has no order + content: + application/json: + schema: + $ref: "#/components/schemas/MyOrders" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + + /api/admin/orders: + get: + operationId: adminListOrders + tags: [Admin] + summary: List weeks or one week's orders + security: + - portalCognito: [] + parameters: + - name: week + in: query + schema: + type: string + responses: + "200": + description: Weeks list or week detail + content: + application/json: + schema: + $ref: "#/components/schemas/AdminOrdersResponse" + "403": + $ref: "#/components/responses/StringError" + put: + operationId: adminUpdateOrder + tags: [Admin] + summary: Recalculate and replace an order + security: + - portalCognito: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminUpdateBody" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMutation" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "503": + $ref: "#/components/responses/StringError" + delete: + operationId: adminDeleteOrder + tags: [Admin] + summary: Delete an order + security: + - portalCognito: [] + parameters: + - name: week + in: query + required: true + schema: + type: string + - name: email + in: query + required: true + schema: + type: string + responses: + "200": + description: Deleted + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMutation" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + + /api/admin/summary-pdf: + get: + operationId: adminSummaryPdf + tags: [Admin] + summary: Presigned summary PDF URL + security: + - portalCognito: [] + parameters: + - name: week + in: query + required: true + schema: + type: string + responses: + "200": + description: Short-lived URL + content: + application/json: + schema: + $ref: "#/components/schemas/SummaryPdf" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + "404": + $ref: "#/components/responses/StringError" + "500": + $ref: "#/components/responses/StringError" + + /api/roster: + get: + operationId: getRoster + tags: [Orders] + summary: Name and email list used by the static form + security: [] + responses: + "200": + description: Roster + content: + application/json: + schema: + $ref: "#/components/schemas/FormRoster" + "403": + $ref: "#/components/responses/StringError" + + /api/publish/settings: + get: + operationId: publishSettings + tags: [Publish] + summary: Bulk discount and subsidy for scrape + security: + - publishKey: [] + responses: + "200": + description: Pricing fields only + content: + application/json: + schema: + $ref: "#/components/schemas/PublishSettings" + "403": + $ref: "#/components/responses/StringError" + + /api/publish/menu: + post: + operationId: publishMenu + tags: [Publish] + summary: Write this week's scraped menu + security: + - publishKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishMenuBody" + responses: + "200": + description: Published + content: + application/json: + schema: + $ref: "#/components/schemas/PublishMenuResult" + "400": + $ref: "#/components/responses/StringError" + "403": + $ref: "#/components/responses/StringError" + +components: + securitySchemes: + portalCognito: + type: http + scheme: bearer + bearerFormat: JWT + description: Portal Cognito ID token. GIS google_id_token is also accepted on submit until cutover. + publishKey: + type: apiKey + in: header + name: X-Meals-Publish-Key + + parameters: + MenuWeekPath: + name: week + in: path + required: true + description: "`current` or `YYYY-WNN`. Other values are 400." + schema: + type: string + minLength: 1 + OrderWeekPath: + name: week + in: path + required: true + description: "`YYYY-WNN` or `YYYY-MM-DD`. `current` is 400." + schema: + type: string + minLength: 1 + + responses: + StringError: + description: Current meals JSON error + content: + application/json: + schema: + $ref: "#/components/schemas/StringErrorBody" + + schemas: + Health: + type: object + additionalProperties: false + required: [stage, sha] + properties: + stage: + type: string + minLength: 1 + description: Workspace stage (`dev`, `prod`, or `local`) + sha: + type: string + minLength: 1 + description: Git SHA or `unknown` locally + + StringErrorBody: + type: object + additionalProperties: false + required: [error] + properties: + error: + type: string + minLength: 1 + + Meal: + type: object + additionalProperties: true + required: [name, price] + properties: + name: + type: string + price: + type: number + calories: + type: [string, number, "null"] + protein: + type: [string, number, "null"] + description: + type: [string, "null"] + dietary_tags: + type: [array, "null"] + items: + type: string + image_url: + type: [string, "null"] + is_new: + type: boolean + + MenuPayload: + type: object + additionalProperties: false + required: + - week + - form_status + - meal_count + - meals + - bulk_discount_percent + - company_subsidy_percent + properties: + week: + type: string + form_status: + type: string + scraped_at: + type: [string, "null"] + menu_url: + type: [string, "null"] + meal_count: + type: integer + meals: + type: array + items: + $ref: "#/components/schemas/Meal" + bulk_discount_percent: + type: number + company_subsidy_percent: + type: number + order_deadline: + type: string + + FormStatus: + type: object + additionalProperties: false + required: [week, status] + properties: + week: + type: string + status: + type: string + reopen_at: + type: integer + + SubmitItem: + type: object + additionalProperties: true + required: [name, quantity] + properties: + name: + type: string + quantity: + type: number + retail_price: + type: number + + SubmitBody: + type: object + additionalProperties: true + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/SubmitItem" + google_id_token: + type: string + + SubmitResult: + type: object + additionalProperties: false + required: [status] + properties: + status: + type: string + message: + type: string + total: + type: number + + MyOrders: + type: object + additionalProperties: false + required: [week, orders] + properties: + week: + type: string + orders: + type: array + items: + type: object + additionalProperties: false + required: [employee_email] + properties: + employee_email: + type: string + + AdminWeek: + type: object + additionalProperties: true + required: [week] + properties: + week: + type: string + form_status: + type: string + meal_count: + type: integer + order_count: + type: integer + + AdminOrderItem: + type: object + additionalProperties: false + required: [name, quantity, retail_price, price, subtotal] + properties: + name: + type: string + quantity: + type: integer + retail_price: + type: number + price: + type: number + subtotal: + type: number + + AdminOrder: + type: object + additionalProperties: false + required: [employee_name, employee_email, items, total, submitted_at] + properties: + employee_name: + type: string + employee_email: + type: string + items: + type: array + items: + $ref: "#/components/schemas/AdminOrderItem" + total: + type: number + submitted_at: + type: string + + AdminWeeks: + type: object + additionalProperties: false + required: [weeks] + properties: + weeks: + type: array + items: + $ref: "#/components/schemas/AdminWeek" + + AdminWeekOrders: + type: object + additionalProperties: false + required: [week, orders, total_employees, grand_total] + properties: + week: + type: string + orders: + type: array + items: + $ref: "#/components/schemas/AdminOrder" + total_employees: + type: integer + grand_total: + type: number + + AdminOrdersResponse: + oneOf: + - $ref: "#/components/schemas/AdminWeeks" + - $ref: "#/components/schemas/AdminWeekOrders" + + AdminUpdateBody: + type: object + additionalProperties: false + required: [week, email, items] + properties: + week: + type: string + email: + type: string + items: + type: array + items: + $ref: "#/components/schemas/SubmitItem" + + AdminMutation: + type: object + additionalProperties: false + required: [status, week] + properties: + status: + type: string + week: + type: string + email: + type: string + total: + type: number + + SummaryPdf: + type: object + additionalProperties: false + required: [week, url] + properties: + week: + type: string + url: + type: string + format: uri + + FormRoster: + type: object + additionalProperties: false + required: [employees] + properties: + employees: + type: array + items: + type: object + additionalProperties: false + required: [name, email] + properties: + name: + type: string + email: + type: string + + PublishSettings: + type: object + additionalProperties: false + required: [bulk_discount_percent, company_subsidy_percent] + properties: + bulk_discount_percent: + type: number + company_subsidy_percent: + type: number + + PublishMenuBody: + type: object + additionalProperties: true + required: [meals] + properties: + meals: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Meal" + scraped_at: + type: string + menu_url: + type: string + + PublishMenuResult: + type: object + additionalProperties: false + required: [status, week, meal_count] + properties: + status: + type: string + week: + type: string + meal_count: + type: integer diff --git a/package-lock.json b/package-lock.json index 87536c0..fa60137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "devDependencies": { "@eslint/js": "10.0.1", + "@redocly/cli": "2.52.1", "eslint": "10.10.0", "globals": "17.12.0", "prettier": "3.9.8" @@ -256,6 +257,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@redocly/cli": { + "version": "2.52.1", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.52.1.tgz", + "integrity": "sha512-gwfx1WelVDHU/cw+GkpLV9+xksOI3obJGgL1nUVrAFUyXNDxM+aifXDUZk7BfbOIpF69Sea2ant0cOqbQfg/KQ==", + "dev": true, + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", diff --git a/package.json b/package.json index c5048d8..153dea4 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "check:templates": "npm run lint:templates && npm run format:templates", "format:templates": "prettier --check \"src/server/templates/*.js\"", "format:templates:write": "prettier --write \"src/server/templates/*.js\"", - "lint:templates": "eslint \"src/server/templates/*.js\"" + "lint:templates": "eslint \"src/server/templates/*.js\"", + "openapi:lint": "redocly lint --config .redocly.yaml openapi.yaml" }, "devDependencies": { "@eslint/js": "10.0.1", + "@redocly/cli": "2.52.1", "eslint": "10.10.0", "globals": "17.12.0", "prettier": "3.9.8" diff --git a/src/server/http_api.py b/src/server/http_api.py index cc32671..5e96618 100644 --- a/src/server/http_api.py +++ b/src/server/http_api.py @@ -279,15 +279,20 @@ def lambda_handler(event, context): return response(405, {"error": "Method not allowed"}) -def handle_menu(event): - """Return the published menu in the portal's public MenuPayload shape.""" +def _week_from_path(event): requested_week = (event.get("pathParameters") or {}).get("week", "") if requested_week == "current": - week = current_week() - elif re.fullmatch(r"\d{4}-W\d{2}", requested_week): - week = requested_week - else: - return response(400, {"error": "week path param must be current or YYYY-WNN"}) + return current_week(), None + if re.fullmatch(r"\d{4}-W\d{2}", requested_week): + return requested_week, None + return None, response(400, {"error": "week path param must be current or YYYY-WNN"}) + + +def handle_menu(event): + """Return the published menu in the portal's public MenuPayload shape.""" + week, error = _week_from_path(event) + if error is not None: + return error menu = get_menu(week) if menu is None: @@ -623,7 +628,9 @@ def handle_my_orders(event): def handle_form_status(event): - week = event.get("pathParameters", {}).get("week", current_week()) + week, error = _week_from_path(event) + if error is not None: + return error status = get_form_status(week) result = {"week": week, "status": status} if status == "closed": diff --git a/terraform/data.tf b/terraform/data.tf index 33df82e..6e3d503 100644 --- a/terraform/data.tf +++ b/terraform/data.tf @@ -69,6 +69,13 @@ check "dev_has_no_custom_domain" { } } +check "prod_reuses_afterhours_vpc" { + assert { + condition = !local.is_prod || var.existing_vpc_id != "" + error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60." + } +} + check "existing_vpc_pair" { assert { condition = (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0) diff --git a/terraform/locals.tf b/terraform/locals.tf index a667f37..a311a55 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -7,8 +7,9 @@ locals { github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" - # Created only when existing_vpc_id is empty. 10.60 is unused in 011934824531. - manage_vpc = var.existing_vpc_id == "" + # Created only when existing_vpc_id is empty. Prod attaches to afterhours 10.70. + # 10.60 is the unused fallback CIDR, not a second prod VPC. + manage_vpc = var.existing_vpc_id == "" && !local.is_prod vpc_cidr = "10.60.0.0/16" public_subnet_cidrs = ["10.60.0.0/24", "10.60.1.0/24"] diff --git a/terraform/outputs.tf b/terraform/outputs.tf index a28d665..766e28c 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -57,3 +57,13 @@ output "ecs_task_role_arn" { description = "ECS task role; paychex-checkcomponents queue policy must allow this ARN." value = aws_iam_role.ecs_task.arn } + +output "vpc_id" { + description = "VPC the ALB and Fargate tasks run in. Prod attaches to afterhours." + value = local.vpc_id +} + +output "public_subnet_ids" { + description = "Public subnet IDs for the ALB and Fargate tasks." + value = local.public_subnet_ids +} diff --git a/terraform/variables.tf b/terraform/variables.tf index e057094..7a10c92 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -93,7 +93,7 @@ variable "checkcomponents_queue_arn" { } variable "existing_vpc_id" { - description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one." + description = "When set, place the ALB and Fargate tasks in this VPC instead of creating one. Prod attaches to the afterhours VPC." type = string default = "" } diff --git a/terraform/vpc.tf b/terraform/vpc.tf index 8c28033..8433c4b 100644 --- a/terraform/vpc.tf +++ b/terraform/vpc.tf @@ -4,7 +4,7 @@ data "aws_availability_zones" "available" { } data "aws_vpc" "existing" { - count = local.manage_vpc ? 0 : 1 + count = var.existing_vpc_id == "" ? 0 : 1 id = var.existing_vpc_id } @@ -13,6 +13,17 @@ data "aws_subnet" "existing_public" { id = each.value } +resource "terraform_data" "prod_requires_afterhours_vpc" { + input = var.existing_vpc_id + + lifecycle { + precondition { + condition = !local.is_prod || var.existing_vpc_id != "" + error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60." + } + } +} + resource "aws_vpc" "this" { count = local.manage_vpc ? 1 : 0 @@ -24,6 +35,13 @@ resource "aws_vpc" "this" { Name = "${local.project}-vpc" } + lifecycle { + precondition { + condition = !local.is_prod + error_message = "Prod must set existing_vpc_id to the afterhours VPC. Do not mint 10.60." + } + } + # First apply updates the live hcptf apply role before CreateVpc. depends_on = [ aws_iam_role_policy_attachments_exclusive.hcptf_apply, @@ -80,7 +98,7 @@ resource "aws_route_table_association" "public" { } locals { - vpc_id = local.manage_vpc ? aws_vpc.this[0].id : data.aws_vpc.existing[0].id + vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id) public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids } diff --git a/tests/test_openapi_contract.py b/tests/test_openapi_contract.py new file mode 100644 index 0000000..7676ca0 --- /dev/null +++ b/tests/test_openapi_contract.py @@ -0,0 +1,45 @@ +"""OpenAPI 3.1 + Redocly recommended, matching internal-portal (DEV-289).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_openapi_uses_redocly_recommended(): + redocly = (ROOT / ".redocly.yaml").read_text() + package = (ROOT / "package.json").read_text() + spec = (ROOT / "openapi.yaml").read_text() + ci = (ROOT / ".github" / "workflows" / "ci.yml").read_text() + assert "extends:" in redocly + assert "- recommended" in redocly + assert "operation-2xx-response: off" in redocly + assert "operation-4xx-response: error" in redocly + assert "rule/operation-2xx-or-3xx-response" in redocly + assert "severity: error" in redocly + health = spec.split("/api/health:", 1)[1].split("\n /", 1)[0] + assert '"403":' in health + assert '"400":' not in health + roster = spec.split("/api/roster:", 1)[1].split("\n /", 1)[0] + assert '"403":' in roster + assert '"400":' not in roster + form_status = spec.split("/api/form-status/{week}:", 1)[1].split("\n /", 1)[0] + assert '"400":' in form_status + menu = spec.split("/api/menu/{week}:", 1)[1].split("\n /", 1)[0] + orders = spec.split("/api/orders/{week}:", 1)[1].split("\n /", 1)[0] + assert "MenuWeekPath" in menu + assert "MenuWeekPath" in form_status + assert "OrderWeekPath" in orders + assert "WeekPath" not in spec.split("components:", 1)[1].split("MenuWeekPath", 1)[0] + assert "`current` or `YYYY-WNN`" in spec + assert "`YYYY-WNN` or `YYYY-MM-DD`" in spec + meal = spec.split(" Meal:", 1)[1].split("\n MenuPayload:", 1)[0] + assert 'type: [string, number, "null"]' in meal + assert 'type: [string, "null"]' in meal + assert 'menu_url:\n type: [string, "null"]' in spec + assert "root: openapi.yaml" in redocly + assert '"openapi:lint"' in package + assert '"@redocly/cli": "2.52.1"' in package + assert "npm run openapi:lint" in ci + assert "openapi: 3.1.0" in spec + assert "required: [stage, sha]" in spec + assert "{ error: string }" in spec or "`{ error: string }`" in spec diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index c69fde9..c583893 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -1446,6 +1446,33 @@ def test_form_status_open(mock_week, mock_status): assert "reopen_at" not in body, "reopen_at should NOT be present when form is open" +@patch("submit_order_handler.get_form_status", return_value="open") +@patch("submit_order_handler.current_week", return_value="2026-W20") +def test_form_status_current_maps_to_current_week(mock_week, mock_status): + from submit_order_handler import lambda_handler + + event = _make_event( + method="GET", path="/form-status/current", path_parameters={"week": "current"} + ) + status, body = _parse_response(lambda_handler(event, None)) + assert status == 200, f"Expected 200, got {status}: {body}" + assert body["week"] == "2026-W20" + mock_status.assert_called_once_with("2026-W20") + + +@patch("submit_order_handler.get_form_status") +def test_form_status_rejects_invalid_week(mock_status): + from submit_order_handler import lambda_handler + + event = _make_event( + method="GET", path="/form-status/nope", path_parameters={"week": "nope"} + ) + status, body = _parse_response(lambda_handler(event, None)) + assert status == 400, f"Expected 400, got {status}: {body}" + assert "week path param" in body["error"] + mock_status.assert_not_called() + + @patch("submit_order_handler.get_form_status", return_value="closed") @patch("submit_order_handler.current_week", return_value="2026-W20") def test_form_status_closed_reopen_at(mock_week, mock_status): @@ -2137,6 +2164,24 @@ def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get): assert body == {"week": "2026-W36", "orders": []} +@patch("submit_order_handler.get_order") +@patch( + "submit_order_handler._verify_portal_token", + return_value={ + "name": "Portal Employee", + "email": "portal.employee@seahavenind.com", + }, +) +@patch("submit_order_handler.looks_like_cognito_token", return_value=True) +def test_my_orders_rejects_current_week(mock_looks_like, mock_portal, mock_get): + status, body = _parse_response( + submit_order_handler.lambda_handler(_orders_event(week="current"), None) + ) + assert status == 400 + assert "YYYY-WNN" in body["error"] + mock_get.assert_not_called() + + def test_my_orders_requires_bearer(): status, body = _parse_response( submit_order_handler.lambda_handler(_orders_event(token=""), None) diff --git a/tests/test_terraform_vpc.py b/tests/test_terraform_vpc.py index 2e4bc50..188a8c8 100644 --- a/tests/test_terraform_vpc.py +++ b/tests/test_terraform_vpc.py @@ -21,7 +21,9 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default(): assert "count = local.manage_vpc ? 1 : 0" in vpc assert 'variable "existing_vpc_id"' in variables assert 'variable "existing_public_subnet_ids"' in variables - assert 'manage_vpc = var.existing_vpc_id == ""' in locals_tf + assert ( + 'manage_vpc = var.existing_vpc_id == "" && !local.is_prod' in locals_tf + ) assert 'data "aws_vpc" "default"' not in ecs assert "data.aws_vpc.default" not in ecs assert "data.aws_subnets.default" not in ecs @@ -33,3 +35,11 @@ def test_meals_owns_a_vpc_instead_of_looking_up_default(): assert 'check "existing_subnets_in_vpc"' in data assert "from = aws_vpc.this" in vpc assert "to = aws_vpc.this[0]" in vpc + outputs = _read("outputs.tf") + assert 'output "vpc_id"' in outputs + assert "value = local.vpc_id" in outputs + assert 'output "public_subnet_ids"' in outputs + assert 'check "prod_reuses_afterhours_vpc"' in data + assert "prod_requires_afterhours_vpc" in vpc + assert "Do not mint 10.60." in vpc + assert 'count = var.existing_vpc_id == "" ? 0 : 1' in vpc