diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 00000000..32ec40f3 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,27 @@ +--- +name: verify +description: Drive Motzi's running dev server end-to-end to verify changes — login shortcuts, order flows, admin, jobs, emails +--- + +# Verifying Motzi changes at runtime + +## Handle + +- `bin/dev` runs foreman: Rails web + `bun run build:watch` + Solid Queue worker. If port 3000 is busy it offers an alternate port — check `lsof -iTCP:3001 -sTCP:LISTEN` before starting another server. +- For a throwaway server: `bun run build` (populates `app/assets/builds`), then `DISABLE_SPRING=1 bin/rails server -p `. Worktrees need `bun install` first (no shared node_modules). +- Auth: `GET /dev/login_as_admin` signs in as admin — works for both curl cookie jars (`curl -c jar -L`) and Playwright. +- Admin pages need `-H "Accept: text/html"` with curl or they 302. + +## Flows worth driving + +- **Member order**: `/menu` (React) → pick a day on an item → Add to cart → Submit/Update Order. Credits counter must move; "We've got your order!" confirms. Edit Order → × an item → Update refunds the credit. +- **Guest buy**: `/menu` logged out shows cash prices; cart total fills the "You pay $" box; tip % updates the charge button. Card charge stays disabled in dev — no `STRIPE_PUBLISHABLE_KEY` in `.env` (expect 2 Stripe IntegrationErrors in console; pre-existing, ignore, along with 1 React hooks-order warning in Layout). +- **Admin**: `/admin/orders` (find the order), `/admin/orders//edit` → Update round-trip. `/admin/activity_feed`, `/admin/error_events` (+ `.txt` format), `/admin/uptime_checks`, engines `/blazer`, `/jobs`, `/letter_opener`. +- **Email pipeline**: order updates enqueue `ActionMailer::MailDeliveryJob`; confirm delivery in `/letter_opener` (rich view shows the MJML-rendered email). +- **Jobs**: `psql -d motzi -c "select * from solid_queue_processes"` — supervisor/worker/dispatcher/scheduler heartbeats prove the worker is up. `solid_queue_jobs` timestamps are UTC — don't compare against local `now()`. +- **Probes**: `/nonexistent` → custom 404 via ErrorsController; `/health/admin` without token → 404; `POST /reply_ingress` bad secret → 401; `POST /error_events` JSON → 204 + row in `error_events`. + +## Gotchas + +- Dev DB `motzi` is shared across worktrees and the main checkout — other sessions' migrations show up in your server (PendingMigrationError) and in `db:migrate` schema dumps. +- `AnalyzeAnomaliesJob` fails nightly in dev (Anthropic 400) — pre-existing, not your change. diff --git a/Gemfile b/Gemfile index 48dfc9b8..aecb4db9 100644 --- a/Gemfile +++ b/Gemfile @@ -37,7 +37,7 @@ gem "pg", ">= 0.18", "< 2.0" gem "progress_bar" gem "puma", "~> 7.0" # web/app server gem "rack-attack" # block scanner traffic / rate limit -gem "rails", "~> 7.2.0" +gem "rails", "~> 8.0.3" gem "rails-settings-cached" # site-wide settings gem "redcarpet" # markdown the baker's note in admin gem "sass-rails", "~> 6" # css diff --git a/Gemfile.lock b/Gemfile.lock index 4d4ebe93..623b3f44 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,49 +1,46 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) + actioncable (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailbox (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) - actionmailer (7.2.3.1) - actionpack (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activesupport (= 7.2.3.1) + actionmailer (8.0.5) + actionpack (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activesupport (= 8.0.5) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.3.1) - actionview (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + actionpack (8.0.5) + actionview (= 8.0.5) + activesupport (= 8.0.5) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.3) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.3.1) - actionpack (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + actiontext (8.0.5) + actionpack (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.3.1) - activesupport (= 7.2.3.1) + actionview (8.0.5) + activesupport (= 8.0.5) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) @@ -57,22 +54,22 @@ GEM kaminari (>= 1.2.1) railties (>= 6.1) ransack (>= 4.0) - activejob (7.2.3.1) - activesupport (= 7.2.3.1) + activejob (8.0.5) + activesupport (= 8.0.5) globalid (>= 0.3.6) - activemodel (7.2.3.1) - activesupport (= 7.2.3.1) - activerecord (7.2.3.1) - activemodel (= 7.2.3.1) - activesupport (= 7.2.3.1) + activemodel (8.0.5) + activesupport (= 8.0.5) + activerecord (8.0.5) + activemodel (= 8.0.5) + activesupport (= 8.0.5) timeout (>= 0.4.0) - activestorage (7.2.3.1) - actionpack (= 7.2.3.1) - activejob (= 7.2.3.1) - activerecord (= 7.2.3.1) - activesupport (= 7.2.3.1) + activestorage (8.0.5) + actionpack (= 8.0.5) + activejob (= 8.0.5) + activerecord (= 8.0.5) + activesupport (= 8.0.5) marcel (~> 1.0) - activesupport (7.2.3.1) + activesupport (8.0.5) base64 benchmark (>= 0.3) bigdecimal @@ -81,9 +78,10 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1, < 6) + minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ahoy_email (1.1.1) @@ -437,20 +435,20 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (7.2.3.1) - actioncable (= 7.2.3.1) - actionmailbox (= 7.2.3.1) - actionmailer (= 7.2.3.1) - actionpack (= 7.2.3.1) - actiontext (= 7.2.3.1) - actionview (= 7.2.3.1) - activejob (= 7.2.3.1) - activemodel (= 7.2.3.1) - activerecord (= 7.2.3.1) - activestorage (= 7.2.3.1) - activesupport (= 7.2.3.1) + rails (8.0.5) + actioncable (= 8.0.5) + actionmailbox (= 8.0.5) + actionmailer (= 8.0.5) + actionpack (= 8.0.5) + actiontext (= 8.0.5) + actionview (= 8.0.5) + activejob (= 8.0.5) + activemodel (= 8.0.5) + activerecord (= 8.0.5) + activestorage (= 8.0.5) + activesupport (= 8.0.5) bundler (>= 1.15.0) - railties (= 7.2.3.1) + railties (= 8.0.5) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -461,10 +459,9 @@ GEM rails-settings-cached (2.9.6) activerecord (>= 5.0.0) railties (>= 5.0.0) - railties (7.2.3.1) - actionpack (= 7.2.3.1) - activesupport (= 7.2.3.1) - cgi + railties (8.0.5) + actionpack (= 8.0.5) + activesupport (= 8.0.5) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -706,7 +703,7 @@ DEPENDENCIES puma (~> 7.0) rack-attack rack-mini-profiler (~> 4.0) - rails (~> 7.2.0) + rails (~> 8.0.3) rails-settings-cached rcodetools redcarpet diff --git a/app/controllers/error_events_controller.rb b/app/controllers/error_events_controller.rb index b4d27ddb..bd906c83 100644 --- a/app/controllers/error_events_controller.rb +++ b/app/controllers/error_events_controller.rb @@ -11,6 +11,12 @@ def create end payload = params.permit(:error_class, :message, :stack, :url, context: {}).to_h + # Clients may nest the payload under error_event (the shape ParamsWrapper + # advertises). Top level wins when present: the auto-wrapped copy drops + # keys that aren't ErrorEvent columns (stack, context). + if payload.empty? && params[:error_event].is_a?(ActionController::Parameters) + payload = params[:error_event].permit(:error_class, :message, :stack, :url, context: {}).to_h + end ErrorEvent.record_browser_exception( error_class: payload["error_class"].presence || "Error", diff --git a/app/javascript/packs/menu/Layout.tsx b/app/javascript/packs/menu/Layout.tsx index d056baa3..5a1a0800 100644 --- a/app/javascript/packs/menu/Layout.tsx +++ b/app/javascript/packs/menu/Layout.tsx @@ -42,6 +42,9 @@ export default function Layout({ const [showEmailSettings, setShowEmailSettings] = useState( initialTab === "email" ); + // useContext under the hood — must run before the early returns below so + // the hook order doesn't change once the menu finishes loading. + const deadlineContext = getDeadlineContext(); if (error) { return ( @@ -62,7 +65,7 @@ export default function Layout({ ); } - const orderingClosed = getDeadlineContext().allClosed(menu); + const orderingClosed = deadlineContext.allClosed(menu); if (order && !isEditingOrder) { const handleEditOrder = diff --git a/bin/dev b/bin/dev index bbba1f78..80267f24 100755 --- a/bin/dev +++ b/bin/dev @@ -22,7 +22,7 @@ if lsof -iTCP:"$web_port" -sTCP:LISTEN -t > /dev/null 2>&1; then alt_port=$((alt_port + 1)) done echo "==> Port ${web_port} in use (pid $pid)" - read -rp "==> [u]se port ${alt_port} instead (default) / [k]ill $pid / [q]uit? " answer + read -rp "==> [U]se port ${alt_port} instead (default) / [k]ill $pid / [q]uit? " answer case "${answer:-u}" in [Kk]*) kill "$pid" 2>/dev/null && echo "==> Killed $pid" || echo "==> Could not kill $pid" diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 00000000..40330c0f --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/config/application.rb b/config/application.rb index fb639449..fca1623d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ module Motzi class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 7.2 + config.load_defaults 8.0 # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers diff --git a/config/brakeman.ignore b/config/brakeman.ignore index fa4ed15b..fb205782 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -7,12 +7,8 @@ { "fingerprint": "19ec190c996eca43da675de0907235ecb61b31a18d1b162444c5e6033a901ea4", "note": "Open3.capture2 with array form — no shell injection possible" - }, - { - "fingerprint": "98b26f60d776fd41ee6f088c833725145be9aac2d7c5b33780241c273622db42", - "note": "Rails 7.2 EOL 2026-08-09 — upgrade tracked separately; don't block CI on the countdown. Fingerprint changes with the patch version; re-run bin/brakeman -f json for the new one when it goes stale." } ], - "updated": "2026-07-13", + "updated": "2026-07-14", "brakeman_version": "8.0.4" } diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 35d0f26f..b3076b38 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -1,30 +1,25 @@ # Be sure to restart your server when you modify this file. -# Define an application-wide content security policy -# For further information see the following documentation -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header -# Rails.application.config.content_security_policy do |policy| -# policy.default_src :self, :https -# policy.font_src :self, :https, :data -# policy.img_src :self, :https, :data -# policy.object_src :none -# policy.script_src :self, :https -# policy.style_src :self, :https -# # If you are using webpack-dev-server then specify webpack-dev-server host -# policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? - -# # Specify URI for violation reports -# # policy.report_uri "/csp-violation-report-endpoint" +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true # end - -# If you are using UJS then enable automatic nonce generation -# Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } - -# Set the nonce only to specific directives -# Rails.application.config.content_security_policy_nonce_directives = %w(script-src) - -# Report CSP violations to a specified URI -# For further information see the following documentation: -# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only -# Rails.application.config.content_security_policy_report_only = true diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb index e0202457..d54b2b67 100644 --- a/config/initializers/filter_parameter_logging.rb +++ b/config/initializers/filter_parameter_logging.rb @@ -1,6 +1,8 @@ # Be sure to restart your server when you modify this file. -# Configure sensitive parameters which will be filtered from the log file. +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. Rails.application.config.filter_parameters += [ - :passw, :uid + :passw, :uid, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc ] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9..3860f659 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -4,13 +4,13 @@ # are locale specific, and you may define rules for as many different # locales as you wish. All of these examples are active by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" # inflect.uncountable %w( fish sheep ) # end # These inflection rules are supported but not enabled by default: # ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' +# inflect.acronym "RESTful" # end diff --git a/config/puma.rb b/config/puma.rb index ccadc778..a248513b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,43 +1,41 @@ -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. # -max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } -min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } -threads min_threads_count, max_threads_count - -# Specifies the `worker_timeout` threshold that Puma will use to wait before -# terminating a worker in development environments. +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. # -worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" - -# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. # -port ENV.fetch("PORT") { 3000 } - -# Specifies the `environment` that Puma will run in. +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. # -environment ENV.fetch("RAILS_ENV") { "development" } - -# Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked web server processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. # -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. # -# preload_app! +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) -# Allow puma to be restarted by `rails restart` command. +# Allow puma to be restarted by `bin/rails restart` command. plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb b/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb new file mode 100644 index 00000000..93c8b85a --- /dev/null +++ b/db/migrate/20260714034616_remove_not_null_on_active_storage_blobs_checksum.active_storage.rb @@ -0,0 +1,8 @@ +# This migration comes from active_storage (originally 20211119233751) +class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0] + def change + return unless table_exists?(:active_storage_blobs) + + change_column_null(:active_storage_blobs, :checksum, true) + end +end diff --git a/db/schema.rb b/db/schema.rb index 08198846..ab057c13 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,12 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_11_180000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_14_034616) do create_schema "heroku_ext" # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" - enable_extension "plpgsql" create_table "active_admin_comments", force: :cascade do |t| t.string "namespace" @@ -47,7 +47,7 @@ t.string "content_type" t.text "metadata" t.bigint "byte_size", null: false - t.string "checksum", null: false + t.string "checksum" t.datetime "created_at", precision: nil, null: false t.string "service_name", null: false t.index [ "key" ], name: "index_active_storage_blobs_on_key", unique: true diff --git a/test/integration/error_events_test.rb b/test/integration/error_events_test.rb index f274a085..529e7354 100644 --- a/test/integration/error_events_test.rb +++ b/test/integration/error_events_test.rb @@ -41,6 +41,29 @@ class ErrorEventsTest < ActionDispatch::IntegrationTest assert_nil ErrorEvent.last.user_id end + test "browser ingest accepts a payload nested under error_event" do + # Rails' ParamsWrapper invites this shape (wrap_parameters is on for + # JSON), so a client posting {error_event: {...}} must not silently + # lose every field to the fallbacks. + assert_difference "ErrorEvent.count", 1 do + post "/error_events", params: { + error_event: { + error_class: "TypeError", + message: "wrapped payload", + stack: "TypeError: wrapped\n at App (/menu.js:1:1)", + url: "/menu", + context: { kind: "wrapped" } + } + }, as: :json + end + + assert_response :no_content + ev = ErrorEvent.last + assert_equal "TypeError", ev.error_class + assert_equal "wrapped payload", ev.message + assert_equal "wrapped", ev.context["kind"] + end + test "browser ingest rate-limits high-volume posters" do # The test env's :null_store cache doesn't actually count, so swap in a # real in-memory store for this test only. diff --git a/test/javascript/menu/Layout.test.tsx b/test/javascript/menu/Layout.test.tsx new file mode 100644 index 00000000..0e3fac61 --- /dev/null +++ b/test/javascript/menu/Layout.test.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { afterEach, expect, mock, test } from "bun:test"; +import { cleanup, render } from "@testing-library/react"; + +import Layout from "menu/Layout"; +import { SettingsContext } from "menu/Contexts"; +import mockMenuJson from "./mockMenuJson"; +import stripeMock from "./stripeMock"; + +afterEach(cleanup); + +// Layout renders a loading state before the menu arrives, then the real +// content. Hooks called after the loading-state early return change the +// hook order between those renders, which React reports via console.error. +test("keeps hook order stable when menu loads after the loading state", () => { + window.gon = { stripeApiKey: "no-such-key" }; + window.Stripe = mock(() => stripeMock); + + const originalConsoleError = console.error; + const errors: string[] = []; + console.error = (...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }; + + try { + const { menu, bundles } = mockMenuJson({ user: false, order: false }); + const props = { + bundles, + handleCreateOrder: mock(() => Promise.resolve()), + isEditingOrder: false, + order: null, + setIsEditingOrder: mock(() => {}), + user: null, + }; + + const { rerender } = render( + + + + ); + + rerender( + + + + ); + + const hookOrderErrors = errors.filter((e) => + e.includes("change in the order of Hooks") + ); + expect(hookOrderErrors).toEqual([]); + } finally { + console.error = originalConsoleError; + } +});