From 36ced27154f3f8e183f8f301b9e42f625817e888 Mon Sep 17 00:00:00 2001 From: Ross Reinhardt Date: Mon, 21 Sep 2026 16:11:43 -0400 Subject: [PATCH] Add inbound reply simulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a dev type a reply in the SmsTrap UI and trigger the host app's real inbound-webhook path via SmsTrap.reply_handler, a bare callable the host app configures (mirrors Connector's provider-agnostic shape on the outbound side). The reply form only renders when a handler is configured, so outbound-only apps see no dead UI; reading reply_handler unset raises ReplyHandlerNotConfigured. Also fixes test/dummy's show_exceptions config: Rails 7.2 only treats the symbol :none as "raise instead of render" — the generated boolean false was silently swallowing exceptions in integration tests. --- README.md | 32 ++++++++++-- .../sms_trap/replies_controller.rb | 30 +++++++++++ .../layouts/sms_trap/application.html.erb | 26 ++++++++++ .../sms_trap/conversations/show.html.erb | 7 +++ config/routes.rb | 4 +- lib/sms_trap.rb | 20 ++++++++ test/dummy/config/environments/test.rb | 2 +- test/dummy/config/initializers/sms_trap.rb | 7 +++ test/integration/reply_test.rb | 50 +++++++++++++++++++ test/sms_trap_test.rb | 24 +++++++++ test/test_helper.rb | 10 ++++ 11 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 app/controllers/sms_trap/replies_controller.rb create mode 100644 test/dummy/config/initializers/sms_trap.rb create mode 100644 test/integration/reply_test.rb diff --git a/README.md b/README.md index 5177a45..268b8f7 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,37 @@ Intercepted messages are persisted as files under `tmp/sms_trap` in the host app in memory, so they're visible across every worker process — including a clustered Puma dev server — and survive server restarts until explicitly cleared with `SmsTrap.store.clear`. +## Simulating inbound replies + +Typing a reply in the SmsTrap UI can trigger your app's real inbound-webhook path — not a +fake success, the same code that runs when your provider posts a genuine webhook. Configure +it by setting `SmsTrap.reply_handler` in a development-only initializer to any callable +responding to `#call(from:, to:, text:)`: + +```ruby +# config/initializers/sms_trap.rb +SmsTrap.reply_handler = lambda do |from:, to:, text:| + # Build whatever wire format your provider's webhook expects, and POST it to your app's + # own webhook endpoint (e.g. via ActionDispatch::Integration::Session), the same way your + # provider's real webhook call would land — SmsTrap never touches this format itself. + YourApp::Sms::Adapter.new.simulate_reply(from: from, to: to, text: text) +end +``` + +SmsTrap doesn't know or care what's inside the callable — it stays exactly as +provider-agnostic on the inbound side as `Connector` is on the outbound side. All it +guarantees is that your handler gets *called* with the reply's `from`/`to`/`text`; whether +your app's downstream logic does anything observable (e.g. matching an active conversation +thread) is entirely up to your app's real code, same as it would be for a genuine provider +webhook. + +If no `reply_handler` is configured, the reply form doesn't render at all — apps that don't +need inbound simulation don't need to set anything up. + ## Roadmap -Inbound reply simulation — typing a reply in the SmsTrap UI and having it flow back into -your app exactly as a real inbound webhook would — is planned for a later release. Its -interface will be designed once outbound interception has seen real usage. +None currently — outbound interception and inbound reply simulation are both shipped. +Feature requests are welcome via GitHub issues. ## Contributing diff --git a/app/controllers/sms_trap/replies_controller.rb b/app/controllers/sms_trap/replies_controller.rb new file mode 100644 index 0000000..f456e60 --- /dev/null +++ b/app/controllers/sms_trap/replies_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module SmsTrap + # Handles a dev submitting a reply in the UI: triggers the host app's real inbound path via + # {SmsTrap.reply_handler}, then records the reply so it shows up as an inbound bubble. + class RepliesController < ApplicationController + def create + conversation = find_conversation + return head(:not_found) unless conversation + + simulate_reply(conversation) + redirect_to conversation_path(conversation) + end + + private + + def find_conversation + SmsTrap.store.conversations.find { |c| c.to_param == params[:conversation_id] } + end + + def simulate_reply(conversation) + from = conversation.their_number + to = conversation.our_number + text = params[:text] + + SmsTrap.reply_handler.call(from: from, to: to, text: text) + SmsTrap.store.record(from: from, to: to, text: text, direction: 'inbound') + end + end +end diff --git a/app/views/layouts/sms_trap/application.html.erb b/app/views/layouts/sms_trap/application.html.erb index 58d609a..dd5f0b0 100644 --- a/app/views/layouts/sms_trap/application.html.erb +++ b/app/views/layouts/sms_trap/application.html.erb @@ -120,6 +120,32 @@ background: #e5e5ea; color: #1c1c1e; } + + .sms-trap-reply-form { + display: flex; + gap: 8px; + margin-top: 16px; + } + + .sms-trap-reply-input { + flex: 1; + padding: 10px 14px; + border: 1px solid #d1d1d6; + border-radius: 18px; + font-size: 14px; + font-family: inherit; + } + + .sms-trap-reply-submit { + padding: 10px 18px; + border: none; + border-radius: 18px; + background: #007aff; + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + } diff --git a/app/views/sms_trap/conversations/show.html.erb b/app/views/sms_trap/conversations/show.html.erb index 6d99afe..8f4f6ca 100644 --- a/app/views/sms_trap/conversations/show.html.erb +++ b/app/views/sms_trap/conversations/show.html.erb @@ -14,5 +14,12 @@ <% end %> + + <% if SmsTrap.reply_handler? %> + <%= form_with url: conversation_reply_path(@conversation), class: "sms-trap-reply-form" do |f| %> + <%= f.text_field :text, placeholder: "Type a reply…", class: "sms-trap-reply-input", autocomplete: "off" %> + <%= f.submit "Send", class: "sms-trap-reply-submit" %> + <% end %> + <% end %> diff --git a/config/routes.rb b/config/routes.rb index 6cded29..ab763c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,5 +2,7 @@ SmsTrap::Engine.routes.draw do root to: 'conversations#index' - resources :conversations, only: %i[index show] + resources :conversations, only: %i[index show] do + resource :reply, only: :create + end end diff --git a/lib/sms_trap.rb b/lib/sms_trap.rb index 25bbf12..ed7ecc7 100644 --- a/lib/sms_trap.rb +++ b/lib/sms_trap.rb @@ -13,10 +13,30 @@ module SmsTrap class Error < StandardError; end + # Raised when {reply_handler} is read before the host app has configured one. + class ReplyHandlerNotConfigured < Error; end + class << self # @return [Store] the process-wide store every {Connector} records into by default def store @store ||= Store.new end + + # @param handler [#call] called with from:/to:/text: when a dev submits a reply in the + # UI, to trigger the host app's real inbound path — see README for wiring examples + attr_writer :reply_handler + + # @return [#call] the configured reply handler + # @raise [ReplyHandlerNotConfigured] if the host app hasn't set one + def reply_handler + @reply_handler || raise(ReplyHandlerNotConfigured, + 'Set SmsTrap.reply_handler in an initializer, e.g. ' \ + 'SmsTrap.reply_handler = ->(from:, to:, text:) { ... }. See README.') + end + + # @return [Boolean] whether a reply handler has been configured + def reply_handler? + @reply_handler.present? + end end end diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb index 6ea4d1e..704d445 100644 --- a/test/dummy/config/environments/test.rb +++ b/test/dummy/config/environments/test.rb @@ -28,7 +28,7 @@ config.cache_store = :null_store # Raise exceptions instead of rendering exception templates. - config.action_dispatch.show_exceptions = false + config.action_dispatch.show_exceptions = :none # Disable request forgery protection in test environment. config.action_controller.allow_forgery_protection = false diff --git a/test/dummy/config/initializers/sms_trap.rb b/test/dummy/config/initializers/sms_trap.rb new file mode 100644 index 0000000..184b209 --- /dev/null +++ b/test/dummy/config/initializers/sms_trap.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Stand-in for wherever a real host app wires SmsTrap.reply_handler to its real inbound +# webhook path. The dummy app has no such path, so this just logs. +SmsTrap.reply_handler = lambda do |from:, to:, text:| + Rails.logger.info("[dummy] simulated inbound reply from #{from} to #{to}: #{text}") +end diff --git a/test/integration/reply_test.rb b/test/integration/reply_test.rb new file mode 100644 index 0000000..f83847d --- /dev/null +++ b/test/integration/reply_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ReplyTest < ActionDispatch::IntegrationTest + setup { SmsTrap.store.clear } + + test 'submitting a reply invokes the configured reply_handler and records an inbound message' do + SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there') + conversation = SmsTrap.store.conversations.first + invocations = [] + + with_reply_handler(->(from:, to:, text:) { invocations << { from: from, to: to, text: text } }) do + post "/sms_trap/conversations/#{conversation.to_param}/reply", params: { text: 'got it, thanks' } + end + + assert_redirected_to "/sms_trap/conversations/#{conversation.to_param}" + assert_equal [{ from: '+15552223333', to: '+15550001111', text: 'got it, thanks' }], invocations + + get "/sms_trap/conversations/#{conversation.to_param}" + assert_match 'sms-trap-bubble--inbound', response.body + assert_match 'got it, thanks', response.body + end + + test 'show renders the reply form only when a reply_handler is configured' do + SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there') + conversation = SmsTrap.store.conversations.first + + with_reply_handler(->(from:, to:, text:) {}) do + get "/sms_trap/conversations/#{conversation.to_param}" + assert_select 'form.sms-trap-reply-form', 1 + end + + with_reply_handler(nil) do + get "/sms_trap/conversations/#{conversation.to_param}" + assert_select 'form.sms-trap-reply-form', 0 + end + end + + test 'submitting a reply without a configured reply_handler raises' do + SmsTrap::Connector.new.send_message(from: '+15550001111', to: '+15552223333', text: 'hello there') + conversation = SmsTrap.store.conversations.first + + with_reply_handler(nil) do + assert_raises(SmsTrap::ReplyHandlerNotConfigured) do + post "/sms_trap/conversations/#{conversation.to_param}/reply", params: { text: 'got it' } + end + end + end +end diff --git a/test/sms_trap_test.rb b/test/sms_trap_test.rb index 3644a9e..13ff75a 100644 --- a/test/sms_trap_test.rb +++ b/test/sms_trap_test.rb @@ -6,4 +6,28 @@ class SmsTrapTest < ActiveSupport::TestCase test 'it has a version number' do assert SmsTrap::VERSION end + + test 'reply_handler returns the configured handler' do + handler = ->(from:, to:, text:) {} + + with_reply_handler(handler) do + assert_equal handler, SmsTrap.reply_handler + end + end + + test 'reply_handler raises a clear error when unset' do + with_reply_handler(nil) do + assert_raises(SmsTrap::ReplyHandlerNotConfigured) { SmsTrap.reply_handler } + end + end + + test 'reply_handler? reflects whether a handler is configured' do + with_reply_handler(nil) do + assert_not SmsTrap.reply_handler? + end + + with_reply_handler(->(from:, to:, text:) {}) do + assert SmsTrap.reply_handler? + end + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 19efa6f..7804ad4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -19,5 +19,15 @@ module ActiveSupport class TestCase teardown { SmsTrap.store.clear } + + # Temporarily overrides SmsTrap.reply_handler for the duration of the block, restoring + # whatever was configured before (e.g. the dummy app's initializer) afterward. + def with_reply_handler(handler) + original = SmsTrap.instance_variable_get(:@reply_handler) + SmsTrap.reply_handler = handler + yield + ensure + SmsTrap.instance_variable_set(:@reply_handler, original) + end end end