From ad3e7268305ad4a1ca6281879b4c52db93c84132 Mon Sep 17 00:00:00 2001 From: Tim Walsh Date: Fri, 9 Oct 2026 01:25:28 -0700 Subject: [PATCH] Implement Phase 4: previews, development inbox, generators, allowlist Smswire::Preview and a previews page that renders each message in a phone frame with encoding, segment count, remaining characters, and the characters that force UCS-2. A development inbox lists deliveries and inbound messages and simulates replies through the shared inbound handler. Both are enabled with config.smswire.show_previews and show_inbox, default to development only, and register preview paths for autoloading the way Action Mailer does. Generators for install (migrations, initializer, ApplicationMessenger, engine mount), messengers (class, templates, preview, test), and provider adapters. An allowlist interceptor suppresses or redirects messages to unlisted numbers for staging. The dummy app now uses a file-backed SQLite database, since an in-memory one is lost when a second connection opens, and every test class resets the Smswire tables before running. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L5SuBiSMN4r9qr5CXbKkHV --- .gitignore | 3 + CHANGELOG.md | 8 ++ README.md | 50 +++++++- .../smswire/development_controller.rb | 17 +++ .../smswire/inbound_messages_controller.rb | 20 +--- app/controllers/smswire/inbox_controller.rb | 31 +++++ .../smswire/previews_controller.rb | 22 ++++ .../layouts/smswire/application.html.erb | 60 ++++++++++ app/views/smswire/inbox/index.html.erb | 56 +++++++++ app/views/smswire/previews/index.html.erb | 21 ++++ app/views/smswire/previews/show.html.erb | 34 ++++++ config/routes.rb | 6 + docs/SPEC.md | 23 +++- .../smswire/install/install_generator.rb | 44 +++++++ .../templates/application_messenger.rb.tt | 3 + .../install/templates/initializer.rb.tt | 30 +++++ .../smswire/messenger/messenger_generator.rb | 51 ++++++++ .../messenger/templates/messenger.rb.tt | 14 +++ .../smswire/messenger/templates/preview.rb.tt | 13 +++ .../smswire/messenger/templates/test.rb.tt | 18 +++ .../messenger/templates/view.text.erb.tt | 1 + .../smswire/provider/provider_generator.rb | 36 ++++++ .../smswire/provider/templates/provider.rb.tt | 36 ++++++ .../smswire/provider/templates/test.rb.tt | 25 ++++ lib/smswire.rb | 1 + lib/smswire/configuration.rb | 9 ++ lib/smswire/engine.rb | 12 ++ lib/smswire/inbound_handler.rb | 29 +++++ lib/smswire/interceptors/allowlist.rb | 36 ++++++ lib/smswire/preview.rb | 54 +++++++++ lib/smswire/segments.rb | 12 ++ test/allowlist_test.rb | 38 ++++++ test/development_tools_test.rb | 85 ++++++++++++++ test/dummy/config/application.rb | 3 + test/dummy/config/database.yml | 3 +- .../previews/order_messenger_preview.rb | 17 +++ test/generators_test.rb | 109 ++++++++++++++++++ test/inbound_test.rb | 6 +- test/status_callbacks_test.rb | 7 +- test/test_helper.rb | 22 +++- 40 files changed, 1028 insertions(+), 37 deletions(-) create mode 100644 app/controllers/smswire/development_controller.rb create mode 100644 app/controllers/smswire/inbox_controller.rb create mode 100644 app/controllers/smswire/previews_controller.rb create mode 100644 app/views/layouts/smswire/application.html.erb create mode 100644 app/views/smswire/inbox/index.html.erb create mode 100644 app/views/smswire/previews/index.html.erb create mode 100644 app/views/smswire/previews/show.html.erb create mode 100644 lib/generators/smswire/install/install_generator.rb create mode 100644 lib/generators/smswire/install/templates/application_messenger.rb.tt create mode 100644 lib/generators/smswire/install/templates/initializer.rb.tt create mode 100644 lib/generators/smswire/messenger/messenger_generator.rb create mode 100644 lib/generators/smswire/messenger/templates/messenger.rb.tt create mode 100644 lib/generators/smswire/messenger/templates/preview.rb.tt create mode 100644 lib/generators/smswire/messenger/templates/test.rb.tt create mode 100644 lib/generators/smswire/messenger/templates/view.text.erb.tt create mode 100644 lib/generators/smswire/provider/provider_generator.rb create mode 100644 lib/generators/smswire/provider/templates/provider.rb.tt create mode 100644 lib/generators/smswire/provider/templates/test.rb.tt create mode 100644 lib/smswire/inbound_handler.rb create mode 100644 lib/smswire/interceptors/allowlist.rb create mode 100644 lib/smswire/preview.rb create mode 100644 test/allowlist_test.rb create mode 100644 test/development_tools_test.rb create mode 100644 test/dummy/test/messengers/previews/order_messenger_preview.rb create mode 100644 test/generators_test.rb diff --git a/.gitignore b/.gitignore index aa4cf04..107ab4f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ /.rspec_status /Gemfile.lock .env +/tmp/ +/test/tmp/ +/test/dummy/tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index deb67df..350dee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to this project are documented here. The format follows ### Added +- Messenger previews at `/previews` with a phone frame, encoding, + segment count, remaining characters, and the characters that force UCS-2. +- Development inbox at `/inbox` listing deliveries and inbound + messages, with a form that simulates replies through keyword handling. +- Generators: `smswire:install`, `smswire:messenger`, and `smswire:provider`. +- `Smswire::Interceptors::Allowlist` for staging, suppressing or redirecting + messages to unlisted numbers. + - `Smswire::Consent` with per-sender consent scopes, evidence metadata, and `opt_in!`, `opt_out!`, `opt_out_everywhere!`, and `status_for`. - Inbound webhook endpoint storing `Smswire::InboundMessage` once per diff --git a/README.md b/README.md index 8308736..5b12a4f 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,31 @@ consent and STOP / HELP / START handling, quiet hours, previews, and a [Noticed](https://github.com/excid3/noticed) delivery adapter follow in later phases of the [specification](docs/SPEC.md). -**Status:** pre-release. Phases 1 to 3 of the spec are implemented; the +**Status:** pre-release. Phases 1 to 4 of the spec are implemented; the published `0.0.1.pre` gem only reserves the name. ## Requirements Ruby 3.2+ and Rails 7.1 through 8.1. +## Installation + +```ruby +# Gemfile +gem "smswire" +``` + +``` +bin/rails generate smswire:install +bin/rails db:migrate +bin/rails generate smswire:messenger Order shipped +``` + +The install generator copies the migrations, writes +`config/initializers/smswire.rb` and `ApplicationMessenger`, and mounts the +engine at `/smswire`. The messenger generator writes the class, a template per +action, a preview, and a test. + ## Usage ```ruby @@ -119,6 +137,31 @@ opt-out error from the provider records an opt-out automatically. Smswire provides these mechanisms. Your app is still responsible for its messaging program's legal compliance. +### Development tools + +In development, open `/smswire/previews` to see every preview rendered in a +phone frame with its encoding, segment count, and remaining characters, and +which characters force UCS-2. Open `/smswire/inbox` to see what was sent and +received, and to simulate a reply such as STOP. + +```ruby +# test/messengers/previews/order_messenger_preview.rb +class OrderMessengerPreview < Smswire::Preview + def shipped + OrderMessenger.with(order: Order.first).shipped(User.first) + end +end +``` + +For staging, only let messages through to known numbers: + +```ruby +Smswire.register_interceptor( + Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(",")) + # or redirect_to: "+15555550100" to reroute everything else to one phone +) +``` + ### Providers | Name | Use | @@ -128,8 +171,9 @@ messaging program's legal compliance. | `:test` | Records messages in memory; test default | | `:null` | Accepts and discards | -Register your own with `Smswire::Providers.register(:acme, AcmeProvider)`, -subclassing `Smswire::Providers::Base`. +Generate your own with `bin/rails generate smswire:provider Acme`, or +subclass `Smswire::Providers::Base` and call +`Smswire::Providers.register(:acme, "AcmeProvider")`. ### Testing diff --git a/app/controllers/smswire/development_controller.rb b/app/controllers/smswire/development_controller.rb new file mode 100644 index 0000000..d28c4af --- /dev/null +++ b/app/controllers/smswire/development_controller.rb @@ -0,0 +1,17 @@ +module Smswire + # Base for the development tools. Each subclass names the config flag + # that enables it; when the flag is off the tool answers 404. + class DevelopmentController < ActionController::Base + layout "smswire/application" + + class_attribute :enabled_by + + before_action :require_enabled + + private + + def require_enabled + head :not_found unless Smswire.config.public_send(enabled_by) + end + end +end diff --git a/app/controllers/smswire/inbound_messages_controller.rb b/app/controllers/smswire/inbound_messages_controller.rb index a7d85b3..02a7bc6 100644 --- a/app/controllers/smswire/inbound_messages_controller.rb +++ b/app/controllers/smswire/inbound_messages_controller.rb @@ -14,16 +14,7 @@ def create provider.verify_signature!(request, url: Callbacks.request_url(request)) end - inbound = provider.parse_inbound(request) - payload = {provider: provider.name, provider_id: inbound.provider_id, from: inbound.from} - ActiveSupport::Notifications.instrument("inbound.smswire", payload) do - record = InboundMessage.receive(inbound, provider: provider.name) - payload[:duplicate] = record.nil? - if record - payload[:keyword] = Keywords.handle(record, inbound, provider: provider.name) if Smswire.config.enforce_consent - notify_observers(record) - end - end + InboundHandler.call(provider.parse_inbound(request), provider: provider.name) body, content_type = provider.inbound_response body ? render(plain: body, content_type:) : head(:no_content) @@ -32,14 +23,5 @@ def create rescue SignatureError head :forbidden end - - private - - def notify_observers(record) - Smswire.config.observers.each do |observer| - observer = observer.is_a?(String) ? observer.constantize : observer - observer.sms_received(record) if observer.respond_to?(:sms_received) - end - end end end diff --git a/app/controllers/smswire/inbox_controller.rb b/app/controllers/smswire/inbox_controller.rb new file mode 100644 index 0000000..a8cd0df --- /dev/null +++ b/app/controllers/smswire/inbox_controller.rb @@ -0,0 +1,31 @@ +require "securerandom" + +module Smswire + # Development inbox: recent deliveries and inbound messages, plus a form + # to simulate a reply such as STOP without a real provider. + class InboxController < DevelopmentController + self.enabled_by = :show_inbox + + LIMIT = 50 + + def index + @deliveries = Delivery.recent.limit(LIMIT) + @inbound_messages = InboundMessage.recent.limit(LIMIT) + @default_to = Smswire.config.senders.values.filter_map { |sender| sender.to_h.symbolize_keys[:number] }.first + end + + def receive + from = PhoneNumber.normalize(params[:from]) + if from.nil? || params[:body].blank? + flash[:alert] = "Enter a valid From number and a message body." + return redirect_to(inbox_path) + end + + inbound = Inbound.new(provider_id: "inbox-#{SecureRandom.hex(8)}", from:, to: params[:to].presence, body: params[:body]) + record = InboundHandler.call(inbound, provider: Smswire.config.default_provider || :log) + keyword = record&.reload&.keyword + flash[:notice] = keyword ? "Received from #{from}; handled keyword #{keyword.upcase}." : "Received from #{from}." + redirect_to inbox_path + end + end +end diff --git a/app/controllers/smswire/previews_controller.rb b/app/controllers/smswire/previews_controller.rb new file mode 100644 index 0000000..556dfeb --- /dev/null +++ b/app/controllers/smswire/previews_controller.rb @@ -0,0 +1,22 @@ +module Smswire + class PreviewsController < DevelopmentController + self.enabled_by = :show_previews + + def index + @previews = Preview.all + end + + def show + preview_name, _, message_name = params[:path].rpartition("/") + @preview = Preview.find(preview_name) + return head(:not_found) unless @preview&.message_exists?(message_name) + + @message_name = message_name + @message = @preview.call(message_name) + return render(plain: @message&.body.to_s) if params[:raw].present? + + @analysis = @message && Segments.analyze(@message.body) + @non_gsm = @message ? Segments.non_gsm_characters(@message.body) : [] + end + end +end diff --git a/app/views/layouts/smswire/application.html.erb b/app/views/layouts/smswire/application.html.erb new file mode 100644 index 0000000..ca9ce05 --- /dev/null +++ b/app/views/layouts/smswire/application.html.erb @@ -0,0 +1,60 @@ + + + + + + <%= content_for?(:title) ? yield(:title) : "Smswire" %> + <%= csrf_meta_tags %> + + + +
+ Smswire + <% if Smswire.config.show_previews %><%= link_to "Previews", previews_path, class: ("active" if controller_name == "previews") %><% end %> + <% if Smswire.config.show_inbox %><%= link_to "Inbox", inbox_path, class: ("active" if controller_name == "inbox") %><% end %> +
+
+ <% flash.each do |_type, text| %>
<%= text %>
<% end %> + <%= yield %> +
+ + diff --git a/app/views/smswire/inbox/index.html.erb b/app/views/smswire/inbox/index.html.erb new file mode 100644 index 0000000..068b3c4 --- /dev/null +++ b/app/views/smswire/inbox/index.html.erb @@ -0,0 +1,56 @@ +<% content_for :title, "SMS inbox" %> +

SMS inbox

+ +
+ Simulate a reply +

Runs the same handling as the inbound webhook, including STOP, START, and HELP.

+ <%= form_with url: inbox_inbound_path, class: "inline" do |form| %> + + + + <%= form.submit "Receive" %> + <% end %> +
+ +

Sent

+
+ <% if @deliveries.empty? %> + Nothing sent yet. + <% else %> + + + <% @deliveries.each do |delivery| %> + + + + + + + + <% end %> +
WhenToMessageStatusDetails
<%= time_ago_in_words(delivery.created_at) %> ago<%= delivery.to_number %><%= delivery.body || content_tag(:span, "(body not stored)", class: "muted") %><%= delivery.status %> + <%= [delivery.messenger, delivery.action].compact.join("#") %>
+ <%= delivery.category %> · <%= pluralize(delivery.segments.to_i, "segment") %> · <%= delivery.provider %> + <% if delivery.error_message.present? %>
<%= delivery.error_code %> <%= delivery.error_message %><% end %> +
+ <% end %> +
+ +

Received

+
+ <% if @inbound_messages.empty? %> + Nothing received yet. + <% else %> + + + <% @inbound_messages.each do |message| %> + + + + + + + <% end %> +
WhenFromMessageKeyword
<%= time_ago_in_words(message.created_at) %> ago<%= message.from_number %><%= message.body %><%= message.keyword&.upcase %>
+ <% end %> +
diff --git a/app/views/smswire/previews/index.html.erb b/app/views/smswire/previews/index.html.erb new file mode 100644 index 0000000..0d0ccb5 --- /dev/null +++ b/app/views/smswire/previews/index.html.erb @@ -0,0 +1,21 @@ +<% content_for :title, "SMS previews" %> +

SMS previews

+<% if @previews.empty? %> +

No previews found. Add classes ending in Preview under + <%= safe_join(Smswire.config.preview_paths.map { |path| content_tag(:code, path) }, " or ") %>, + or run bin/rails generate smswire:messenger.

+<% end %> +<% @previews.each do |preview| %> +

<%= preview.preview_name.camelize %>

+
+ <% if preview.messages.empty? %> + No public methods. + <% else %> +
    + <% preview.messages.each do |name| %> +
  • <%= link_to name, preview_path(path: "#{preview.preview_name}/#{name}") %>
  • + <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/smswire/previews/show.html.erb b/app/views/smswire/previews/show.html.erb new file mode 100644 index 0000000..f9ae82f --- /dev/null +++ b/app/views/smswire/previews/show.html.erb @@ -0,0 +1,34 @@ +<% content_for :title, "#{@preview.preview_name.camelize}##{@message_name}" %> +

<%= link_to "All previews", previews_path %>

+

<%= @preview.preview_name.camelize %>#<%= @message_name %>

+ +<% if @message.nil? %> +
This action returned without calling text, so nothing would be sent.
+<% else %> +
+
+
<%= @message.body %>
+ <% @message.media_urls.each do |url| %> +

Media: <%= link_to url, url %>

+ <% end %> +
+
+ + + + + + + + +
To<%= @message.to %>
From<%= @message.from || @message.messaging_service || "(default)" %>
Category<%= @message.category %>
Provider<%= @message.provider || Smswire.config.default_provider %>
Encoding<%= @analysis.encoding_label %>
Segments<%= pluralize(@analysis.segments, "segment") %>
Length<%= @analysis.units %> units, <%= @analysis.remaining %> left in this segment
+

<%= link_to "Raw text", preview_path(path: params[:path], raw: 1) %>

+
+
+ <% if @non_gsm.any? %> +
+ These characters switch the message to UCS-2, which fits 70 units per segment instead of 160: + <%= @non_gsm.join(" ") %> +
+ <% end %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index b604bc0..c30b6b8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,10 @@ Smswire::Engine.routes.draw do post "status/:provider", to: "status_callbacks#create", as: :status_callback post "inbound/:provider", to: "inbound_messages#create", as: :inbound_message + + # Development tools; each answers 404 unless enabled. + get "previews", to: "previews#index", as: :previews + get "previews/*path", to: "previews#show", as: :preview, format: false + get "inbox", to: "inbox#index", as: :inbox + post "inbox/inbound", to: "inbox#receive", as: :inbox_inbound end diff --git a/docs/SPEC.md b/docs/SPEC.md index bcc2901..507b075 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -393,7 +393,23 @@ assert_no_sms_delivered Previews mirror Action Mailer previews, at `test/messengers/previews/` or `spec/messengers/previews/`, served at `/smswire/previews` in development. Each preview renders the body in a phone frame, shows segment count, encoding, -character budget remaining, and the resolved sender. +character budget remaining, and the resolved sender, and names any characters +that force UCS-2. Previews build the message without running interceptors or +sending. + +The development inbox at `/smswire/inbox` lists recent deliveries and inbound +messages, and has a form that simulates a reply such as STOP through the same +handling as the inbound webhook. + +Both tools are enabled with `config.smswire.show_previews` and +`config.smswire.show_inbox` in `config/application.rb` or an environment +file, and default to true in development only. Preview paths are registered +for autoloading before initializers run, as Action Mailer does, so they cannot +be set from `config/initializers`. + +The allowlist interceptor either suppresses messages to unlisted numbers or, +with `redirect_to:`, reroutes them to one number with the original recipient +prefixed to the body. ### 6.8 Observability @@ -435,7 +451,9 @@ quiet hours, persistence, and status tracking all apply. The resulting route mount, credentials scaffold hints. - `rails g smswire:messenger Order shipped delivered` — class, templates, preview, test. -- `rails g smswire:provider Acme` — adapter skeleton with contract tests. +- `rails g smswire:provider Acme` — adapter skeleton in `app/sms_providers`, + a WebMock test, and a registration line in the initializer. The shared + adapter contract suite arrives in Phase 5. ## 7. Configuration surface @@ -567,6 +585,7 @@ next. No phase includes a calendar estimate. - Previews route and UI, local inbox UI, generators, allowlist interceptor. - Accept: `rails g smswire:messenger` output renders in previews with correct segment and encoding display; development deliveries appear in the inbox. +- Status: implemented. ### Phase 5: Breadth diff --git a/lib/generators/smswire/install/install_generator.rb b/lib/generators/smswire/install/install_generator.rb new file mode 100644 index 0000000..6eff96e --- /dev/null +++ b/lib/generators/smswire/install/install_generator.rb @@ -0,0 +1,44 @@ +require "rails/generators" +require "active_record" + +module Smswire + module Generators + # bin/rails generate smswire:install + class InstallGenerator < ::Rails::Generators::Base + source_root File.expand_path("templates", __dir__) + + desc "Copy Smswire migrations, add an initializer and ApplicationMessenger, and mount the engine." + + def copy_migrations + destination = File.join(destination_root, "db/migrate") + FileUtils.mkdir_p(destination) + copied = ActiveRecord::Migration.copy(destination, {smswire: Smswire::Engine.root.join("db/migrate").to_s}) + copied.each { |migration| say_status :create, "db/migrate/#{File.basename(migration.filename)}" } + say_status :identical, "db/migrate (Smswire migrations already installed)" if copied.empty? + end + + def create_initializer + template "initializer.rb", "config/initializers/smswire.rb" + end + + def create_application_messenger + template "application_messenger.rb", "app/messengers/application_messenger.rb" + end + + def mount_engine + route %(mount Smswire::Engine => "/smswire") + end + + def show_next_steps + say <<~TEXT + + Smswire is installed. Next: + 1. bin/rails db:migrate + 2. Set provider credentials, senders, and callbacks_url in config/initializers/smswire.rb + 3. Point your provider's status and inbound webhooks at /smswire/status/ and /smswire/inbound/ + 4. bin/rails generate smswire:messenger Welcome greeting + TEXT + end + end + end +end diff --git a/lib/generators/smswire/install/templates/application_messenger.rb.tt b/lib/generators/smswire/install/templates/application_messenger.rb.tt new file mode 100644 index 0000000..1d44583 --- /dev/null +++ b/lib/generators/smswire/install/templates/application_messenger.rb.tt @@ -0,0 +1,3 @@ +class ApplicationMessenger < Smswire::Base + default from: :transactional +end diff --git a/lib/generators/smswire/install/templates/initializer.rb.tt b/lib/generators/smswire/install/templates/initializer.rb.tt new file mode 100644 index 0000000..12decc5 --- /dev/null +++ b/lib/generators/smswire/install/templates/initializer.rb.tt @@ -0,0 +1,30 @@ +Smswire.configure do |config| + # Development uses the :log provider and tests use :test unless set here. + # config.default_provider = :twilio + + # Credentials default to Rails.application.credentials.twilio. + # config.providers = { + # twilio: {account_sid: ENV["TWILIO_ACCOUNT_SID"], auth_token: ENV["TWILIO_AUTH_TOKEN"]} + # } + + # Named senders. Messengers pick one with `default from: :transactional`. + config.senders = { + transactional: {number: ENV.fetch("SMS_FROM_NUMBER", "+15555550100")} + # marketing: {messaging_service: "MG...", provider: :twilio, consent_scope: "marketing"} + } + + # Public URL where Smswire::Engine is mounted. Enables delivery status callbacks. + # config.callbacks_url = "https://example.com/smswire" + + # Used in STOP / START / HELP auto-replies. + # config.program_name = "<%= Rails.application.class.module_parent_name.titleize rescue "My App" %>" + + # Category rules merge over the defaults (marketing requires opt-in and has quiet hours). + # config.categories = {marketing: {rate_limit: {max: 3, per: 1.day}}} + # config.default_time_zone = "America/New_York" +end + +# Staging safety: only listed numbers receive messages. +# if ENV["SMS_ALLOWLIST"] +# Smswire.register_interceptor(Smswire::Interceptors::Allowlist.new(numbers: ENV["SMS_ALLOWLIST"].split(","))) +# end diff --git a/lib/generators/smswire/messenger/messenger_generator.rb b/lib/generators/smswire/messenger/messenger_generator.rb new file mode 100644 index 0000000..406fa04 --- /dev/null +++ b/lib/generators/smswire/messenger/messenger_generator.rb @@ -0,0 +1,51 @@ +require "rails/generators/named_base" + +module Smswire + module Generators + # bin/rails generate smswire:messenger Order shipped delivered + class MessengerGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + argument :actions, type: :array, default: [], banner: "action action" + + desc "Create a messenger with text templates, a preview, and a test." + + check_class_collision suffix: "Messenger" + + def create_application_messenger + return if File.exist?(File.join(destination_root, "app/messengers/application_messenger.rb")) + + template "../../install/templates/application_messenger.rb", "app/messengers/application_messenger.rb" + end + + def create_messenger + template "messenger.rb", File.join("app/messengers", class_path, "#{file_name}.rb") + end + + def create_templates + actions.each do |action| + @action = action + template "view.text.erb", File.join("app/views", class_path, file_name, "#{action}.text.erb") + end + end + + def create_preview + template "preview.rb", File.join("test/messengers/previews", class_path, "#{file_name}_preview.rb") + end + + def create_test + template "test.rb", File.join("test/messengers", class_path, "#{file_name}_test.rb") + end + + private + + def file_name + @_file_name ||= super.sub(/_messenger\z/i, "") + "_messenger" + end + + def sample_number + "+15555550100" + end + end + end +end diff --git a/lib/generators/smswire/messenger/templates/messenger.rb.tt b/lib/generators/smswire/messenger/templates/messenger.rb.tt new file mode 100644 index 0000000..f17b26b --- /dev/null +++ b/lib/generators/smswire/messenger/templates/messenger.rb.tt @@ -0,0 +1,14 @@ +<% module_namespacing do -%> +class <%= class_name.delete_suffix("Messenger") %>Messenger < ApplicationMessenger +<% actions.each_with_index do |action, index| -%> +<% if index > 0 -%> + +<% end -%> + # Template: app/views/<%= file_path %>/<%= action %>.text.erb + def <%= action %>(recipient) + @greeting = "Hi" + text to: recipient + end +<% end -%> +end +<% end -%> diff --git a/lib/generators/smswire/messenger/templates/preview.rb.tt b/lib/generators/smswire/messenger/templates/preview.rb.tt new file mode 100644 index 0000000..78a5305 --- /dev/null +++ b/lib/generators/smswire/messenger/templates/preview.rb.tt @@ -0,0 +1,13 @@ +# Preview at /smswire/previews/<%= file_path %> +<% module_namespacing do -%> +class <%= class_name.delete_suffix("Messenger") %>MessengerPreview < Smswire::Preview +<% actions.each_with_index do |action, index| -%> +<% if index > 0 -%> + +<% end -%> + def <%= action %> + <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>") + end +<% end -%> +end +<% end -%> diff --git a/lib/generators/smswire/messenger/templates/test.rb.tt b/lib/generators/smswire/messenger/templates/test.rb.tt new file mode 100644 index 0000000..bd83a10 --- /dev/null +++ b/lib/generators/smswire/messenger/templates/test.rb.tt @@ -0,0 +1,18 @@ +require "test_helper" + +<% module_namespacing do -%> +class <%= class_name.delete_suffix("Messenger") %>MessengerTest < ActiveSupport::TestCase + include Smswire::TestHelper +<% actions.each do |action| -%> + + test "<%= action %>" do + message = <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>").message + assert_match "<%= class_name.delete_suffix("Messenger") %>Messenger#<%= action %>", message.body + + assert_sms_delivered(to: "<%= sample_number %>", action: :<%= action %>) do + <%= class_name.delete_suffix("Messenger") %>Messenger.<%= action %>("<%= sample_number %>").deliver_later + end + end +<% end -%> +end +<% end -%> diff --git a/lib/generators/smswire/messenger/templates/view.text.erb.tt b/lib/generators/smswire/messenger/templates/view.text.erb.tt new file mode 100644 index 0000000..f479dd3 --- /dev/null +++ b/lib/generators/smswire/messenger/templates/view.text.erb.tt @@ -0,0 +1 @@ +<%%= @greeting %>, this is <%= class_name.delete_suffix("Messenger") %>Messenger#<%= @action %>. diff --git a/lib/generators/smswire/provider/provider_generator.rb b/lib/generators/smswire/provider/provider_generator.rb new file mode 100644 index 0000000..666603d --- /dev/null +++ b/lib/generators/smswire/provider/provider_generator.rb @@ -0,0 +1,36 @@ +require "rails/generators/named_base" + +module Smswire + module Generators + # bin/rails generate smswire:provider Acme + class ProviderGenerator < ::Rails::Generators::NamedBase + source_root File.expand_path("templates", __dir__) + + desc "Create a Smswire provider adapter skeleton and register it." + + def create_provider + template "provider.rb", File.join("app/sms_providers", class_path, "#{file_name}_provider.rb") + end + + def create_test + template "test.rb", File.join("test/sms_providers", class_path, "#{file_name}_provider_test.rb") + end + + def register_provider + initializer = File.join(destination_root, "config/initializers/smswire.rb") + line = %(\nSmswire::Providers.register(:#{file_name}, "#{provider_class}")\n) + if File.exist?(initializer) + append_to_file "config/initializers/smswire.rb", line + else + say "Register the adapter in an initializer:#{line}" + end + end + + private + + def provider_class + "#{class_name}Provider" + end + end + end +end diff --git a/lib/generators/smswire/provider/templates/provider.rb.tt b/lib/generators/smswire/provider/templates/provider.rb.tt new file mode 100644 index 0000000..b2b93dc --- /dev/null +++ b/lib/generators/smswire/provider/templates/provider.rb.tt @@ -0,0 +1,36 @@ +# Smswire adapter for <%= class_name %>. +# +# Options come from Smswire.config.providers[:<%= file_name %>]. +# Raise Smswire::TransientError for network failures and 5xx responses, +# Smswire::ThrottledError (with retry_after) for rate limiting, and +# Smswire::PermanentError (with a reason) when retrying cannot help. +class <%= provider_class %> < Smswire::Providers::Base + API_URL = "https://api.example.com/messages".freeze + + def deliver(message) + response = Smswire::HTTP.post_form(API_URL, [ + ["to", message.to], + ["from", message.from.to_s], + ["body", message.body.to_s] + ], headers: {"Authorization" => "Bearer #{options.fetch(:api_key)}"}, provider: name) + + json = Smswire::HTTP.parse_json(response.body) + case response.code.to_i + when 200..299 + receipt(provider_id: json["id"], status: :accepted, segments: message.segments, raw: json) + when 429 + raise Smswire::ThrottledError.new("<%= class_name %> rate limited", retry_after: response["Retry-After"]&.to_i, provider: name) + when 500..599 + raise Smswire::TransientError.new("<%= class_name %> #{response.code}", provider: name, http_status: response.code.to_i) + else + raise Smswire::PermanentError.new("<%= class_name %> #{response.code}: #{json["error"]}", + reason: :rejected, provider: name, http_status: response.code.to_i, raw: json) + end + end + + # Add :status_callbacks and :inbound here once verify_signature!, + # parse_status_callback, and parse_inbound are implemented. + def capabilities + Set.new + end +end diff --git a/lib/generators/smswire/provider/templates/test.rb.tt b/lib/generators/smswire/provider/templates/test.rb.tt new file mode 100644 index 0000000..2827b59 --- /dev/null +++ b/lib/generators/smswire/provider/templates/test.rb.tt @@ -0,0 +1,25 @@ +require "test_helper" +require "webmock/minitest" + +class <%= provider_class %>Test < ActiveSupport::TestCase + setup do + @provider = <%= provider_class %>.new({api_key: "test-key"}, name: :<%= file_name %>) + @message = Smswire::Message.new(to: "+15555550100", from: "+15555550199", body: "Hello") + end + + test "delivers and returns a receipt" do + stub_request(:post, <%= provider_class %>::API_URL).to_return(status: 200, body: {id: "msg_1"}.to_json) + receipt = @provider.deliver(@message) + assert_equal "msg_1", receipt.provider_id + end + + test "5xx responses are transient" do + stub_request(:post, <%= provider_class %>::API_URL).to_return(status: 503) + assert_raises(Smswire::TransientError) { @provider.deliver(@message) } + end + + test "4xx responses are permanent" do + stub_request(:post, <%= provider_class %>::API_URL).to_return(status: 400, body: {error: "invalid"}.to_json) + assert_raises(Smswire::PermanentError) { @provider.deliver(@message) } + end +end diff --git a/lib/smswire.rb b/lib/smswire.rb index aa63f3a..5180334 100644 --- a/lib/smswire.rb +++ b/lib/smswire.rb @@ -10,6 +10,7 @@ loader = Zeitwerk::Loader.for_gem loader.inflector.inflect("http" => "HTTP", "rspec" => "RSpec") loader.ignore( + "#{__dir__}/generators", "#{__dir__}/smswire/errors.rb", "#{__dir__}/smswire/engine.rb", "#{__dir__}/smswire/rspec.rb" diff --git a/lib/smswire/configuration.rb b/lib/smswire/configuration.rb index 442b825..c3d795b 100644 --- a/lib/smswire/configuration.rb +++ b/lib/smswire/configuration.rb @@ -107,6 +107,12 @@ class Configuration attr_accessor :program_name + # Development tools served by the engine. Both default to true in + # development only. Set them in config/application.rb or an environment + # file as config.smswire.show_previews, since preview paths are + # registered for autoloading before initializers run. + attr_accessor :show_previews, :show_inbox, :preview_paths + def initialize @default_provider = nil @providers = {} @@ -132,6 +138,9 @@ def initialize @keywords = DEFAULT_KEYWORDS.transform_values(&:dup) @keyword_replies = DEFAULT_KEYWORD_REPLIES.dup @program_name = nil + @show_previews = false + @show_inbox = false + @preview_paths = [] end def phone_validator=(value) diff --git a/lib/smswire/engine.rb b/lib/smswire/engine.rb index 5a45614..aabdb20 100644 --- a/lib/smswire/engine.rb +++ b/lib/smswire/engine.rb @@ -14,6 +14,18 @@ class Engine < ::Rails::Engine config.smswire = ActiveSupport::OrderedOptions.new + # Like Action Mailer: register preview directories for autoloading + # before Rails builds its autoload paths. Declared first because Rails + # chains an engine's initializers in declaration order. + initializer "smswire.preview_paths", before: :set_autoload_paths do |app| + options = app.config.smswire + development = ::Rails.env.development? + options.show_previews = development if options.show_previews.nil? + options.show_inbox = development if options.show_inbox.nil? + options.preview_paths ||= %w[test/messengers/previews spec/messengers/previews].map { |path| "#{app.root}/#{path}" } + app.config.paths.add "smswire/previews", with: options.preview_paths, autoload: true + end + # Runs after config/initializers so app settings win over defaults. initializer "smswire.configure", after: :load_config_initializers do |app| app.config.smswire.each { |key, value| Smswire.config.public_send(:"#{key}=", value) } diff --git a/lib/smswire/inbound_handler.rb b/lib/smswire/inbound_handler.rb new file mode 100644 index 0000000..3623057 --- /dev/null +++ b/lib/smswire/inbound_handler.rb @@ -0,0 +1,29 @@ +module Smswire + # Records an inbound message once, handles compliance keywords, and + # notifies observers. Shared by the webhook endpoint and the development + # inbox. + module InboundHandler + module_function + + # Returns the new Smswire::InboundMessage, or nil for a repeated webhook. + def call(inbound, provider:) + payload = {provider:, provider_id: inbound.provider_id, from: inbound.from} + ActiveSupport::Notifications.instrument("inbound.smswire", payload) do + record = InboundMessage.receive(inbound, provider:) + payload[:duplicate] = record.nil? + if record + payload[:keyword] = Keywords.handle(record, inbound, provider:) if Smswire.config.enforce_consent + notify_observers(record) + end + record + end + end + + def notify_observers(record) + Smswire.config.observers.each do |observer| + observer = observer.is_a?(String) ? observer.constantize : observer + observer.sms_received(record) if observer.respond_to?(:sms_received) + end + end + end +end diff --git a/lib/smswire/interceptors/allowlist.rb b/lib/smswire/interceptors/allowlist.rb new file mode 100644 index 0000000..f063982 --- /dev/null +++ b/lib/smswire/interceptors/allowlist.rb @@ -0,0 +1,36 @@ +module Smswire + module Interceptors + # Staging safety: only listed numbers receive messages. + # + # Smswire.register_interceptor( + # Smswire::Interceptors::Allowlist.new(numbers: ENV.fetch("SMS_ALLOWLIST", "").split(",")) + # ) + # + # Other recipients are suppressed, or with +redirect_to:+ rerouted to + # that number with the original recipient prefixed to the body. + class Allowlist + attr_reader :numbers, :redirect_to + + def initialize(numbers:, redirect_to: nil) + @numbers = Array(numbers).filter_map { |number| PhoneNumber.normalize(number.to_s.strip) }.to_set + @redirect_to = redirect_to && (PhoneNumber.normalize(redirect_to) or + raise ConfigurationError, "Invalid redirect_to number #{redirect_to.inspect}") + end + + def allowed?(number) + numbers.include?(number) + end + + def delivering_sms(message) + return if allowed?(message.to) + + if redirect_to + message.body = "[to #{message.to}] #{message.body}" + message.to = redirect_to + else + message.cancel! + end + end + end + end +end diff --git a/lib/smswire/preview.rb b/lib/smswire/preview.rb new file mode 100644 index 0000000..4992a52 --- /dev/null +++ b/lib/smswire/preview.rb @@ -0,0 +1,54 @@ +require "active_support/core_ext/class/subclasses" + +module Smswire + # Previews for messengers, like ActionMailer::Preview. Put them in + # test/messengers/previews (or spec/messengers/previews) and open + # /previews in development. + # + # class OrderMessengerPreview < Smswire::Preview + # def shipped + # OrderMessenger.with(order: Order.first).shipped(User.first) + # end + # end + # + # Each public method returns a MessageDelivery. Nothing is sent. + class Preview + class << self + def all + load_previews + descendants.reject { |preview| preview.name.nil? }.sort_by(&:preview_name) + end + + def find(preview_name) + all.find { |preview| preview.preview_name == preview_name } + end + + def preview_name + name.delete_suffix("Preview").underscore + end + + def messages + public_instance_methods(false).map(&:to_s).sort + end + + def message_exists?(name) + messages.include?(name.to_s) + end + + # Runs the preview method and returns the built Smswire::Message, or + # nil when the action did not call +text+. Interceptors are not run. + def call(name) + delivery = new.public_send(name) + delivery.respond_to?(:message) ? delivery.message : delivery + end + + private + + def load_previews + Smswire.config.preview_paths.each do |path| + Dir["#{path}/**/*_preview.rb"].sort.each { |file| require file } + end + end + end + end +end diff --git a/lib/smswire/segments.rb b/lib/smswire/segments.rb index d278242..ca6682d 100644 --- a/lib/smswire/segments.rb +++ b/lib/smswire/segments.rb @@ -25,6 +25,13 @@ def gsm7? = encoding == :gsm7 def ucs2? = encoding == :ucs2 def encoding_label = gsm7? ? "GSM-7" : "UCS-2" + + # Units left before the message needs another segment. + def remaining + limits = LIMITS.fetch(encoding) + capacity = (segments <= 1) ? limits[:single] : segments * limits[:multi] + [capacity - units, 0].max + end end module_function @@ -37,6 +44,11 @@ def analyze(text) Analysis.new(encoding:, units:, segments: count(costs, units, LIMITS.fetch(encoding))) end + # Characters that force UCS-2 encoding, in order of appearance. + def non_gsm_characters(text) + text.to_s.chars.reject { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) }.uniq + end + def gsm7?(chars) chars.all? { |char| GSM_BASIC.include?(char) || GSM_EXTENDED.include?(char) } end diff --git a/test/allowlist_test.rb b/test/allowlist_test.rb new file mode 100644 index 0000000..abc398b --- /dev/null +++ b/test/allowlist_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +class AllowlistTest < Smswire::TestCase + def with_interceptor(interceptor) + Smswire.register_interceptor(interceptor) + yield + ensure + Smswire.unregister_interceptor(interceptor) + end + + test "listed numbers are delivered and others are suppressed" do + allowlist = Smswire::Interceptors::Allowlist.new(numbers: ["(415) 555-2671", " +12125550123 ", "bogus"]) + assert_equal Set["+14155552671", "+12125550123"], allowlist.numbers + + with_interceptor(allowlist) do + assert OrderMessenger.literal("415-555-2671", "ok").deliver_now.accepted? + result = OrderMessenger.literal("+13105550188", "blocked").deliver_now + assert result.suppressed? + assert_equal "suppressed", result.delivery.status + end + assert_equal ["+14155552671"], sms_deliveries.map(&:to) + end + + test "redirect mode reroutes and labels the original recipient" do + allowlist = Smswire::Interceptors::Allowlist.new(numbers: [], redirect_to: "+12125550123") + with_interceptor(allowlist) do + assert OrderMessenger.literal("+13105550188", "hello").deliver_now.accepted? + end + message = sms_deliveries.sole + assert_equal "+12125550123", message.to + assert_equal "[to +13105550188] hello", message.body + assert_equal "+12125550123", Smswire::Delivery.sole.to_number + end + + test "an invalid redirect number raises" do + assert_raises(Smswire::ConfigurationError) { Smswire::Interceptors::Allowlist.new(numbers: [], redirect_to: "nope") } + end +end diff --git a/test/development_tools_test.rb b/test/development_tools_test.rb new file mode 100644 index 0000000..98bf58a --- /dev/null +++ b/test/development_tools_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class DevelopmentToolsTest < Smswire::IntegrationTest + test "previews index lists previews and their methods" do + get "/smswire/previews" + assert_response :ok + assert_select "h2", text: "OrderMessenger" + assert_select "a[href='/smswire/previews/order_messenger/shipped']", text: "shipped" + end + + test "a preview renders the body, encoding, and segments without sending" do + get "/smswire/previews/order_messenger/shipped" + assert_response :ok + assert_select(".bubble") do |bubble| + assert_equal "Hi Ada!\nOrder R100 has shipped.\n\nTrack it: http://shop.example/orders/R100", bubble.text + end + assert_select "[data-encoding]", text: "GSM-7" + assert_select "[data-segments]", text: "1 segment" + assert_select "[data-non-gsm]", count: 0 + assert_empty sms_deliveries + assert_equal 0, Smswire::Delivery.count + end + + test "a preview explains UCS-2 and multi-segment messages" do + get "/smswire/previews/order_messenger/unicode" + assert_select "[data-encoding]", text: "UCS-2" + assert_select "[data-non-gsm] code", text: "☕" + + get "/smswire/previews/order_messenger/long" + assert_select "[data-segments]", text: "2 segments" + assert_includes response.body, "200 units, 106 left in this segment" + end + + test "raw text and skipped actions" do + get "/smswire/previews/order_messenger/unicode", params: {raw: 1} + assert_equal "Café ☕ is ready", response.body + + get "/smswire/previews/order_messenger/skipped" + assert_includes response.body, "returned without calling" + end + + test "unknown previews are not found" do + get "/smswire/previews/order_messenger/nope" + assert_response :not_found + get "/smswire/previews/missing/shipped" + assert_response :not_found + end + + test "development deliveries appear in the inbox" do + with_config(default_provider: :log, logger: Logger.new(nil)) do + OrderMessenger.literal("+14155552671", "Your order shipped").deliver_now + end + get "/smswire/inbox" + assert_response :ok + assert_select "td.body", text: "Your order shipped" + assert_select "td.status-accepted", text: "accepted" + end + + test "the inbox simulates replies through keyword handling" do + post "/smswire/inbox/inbound", params: {from: "(415) 555-2671", to: "+15005550006", body: "STOP"} + assert_redirected_to "/smswire/inbox" + follow_redirect! + assert_select ".flash", text: "Received from +14155552671; handled keyword STOP." + assert_equal "opted_out", Smswire::Consent.status_for("+14155552671") + + perform_enqueued_jobs + assert_match(/unsubscribed/, sms_deliveries.sole.body) + + post "/smswire/inbox/inbound", params: {from: "nope", body: "hi"} + follow_redirect! + assert_select ".flash", text: /valid From number/ + end + + test "development tools are hidden when disabled" do + with_config(show_previews: false, show_inbox: false) do + get "/smswire/previews" + assert_response :not_found + get "/smswire/inbox" + assert_response :not_found + post "/smswire/inbox/inbound", params: {from: "+14155552671", body: "STOP"} + assert_response :not_found + end + assert_equal 0, Smswire::Consent.count + end +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index 84f1904..41fdecc 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -12,5 +12,8 @@ class Application < Rails::Application config.logger = Logger.new(nil) config.secret_key_base = "smswire-dummy" config.active_job.queue_adapter = :test + config.action_controller.allow_forgery_protection = false + config.smswire.show_previews = true + config.smswire.show_inbox = true end end diff --git a/test/dummy/config/database.yml b/test/dummy/config/database.yml index e5837c7..abc1028 100644 --- a/test/dummy/config/database.yml +++ b/test/dummy/config/database.yml @@ -1,4 +1,5 @@ test: adapter: sqlite3 - database: ":memory:" + database: tmp/test.sqlite3 pool: 5 + timeout: 5000 diff --git a/test/dummy/test/messengers/previews/order_messenger_preview.rb b/test/dummy/test/messengers/previews/order_messenger_preview.rb new file mode 100644 index 0000000..cf751b9 --- /dev/null +++ b/test/dummy/test/messengers/previews/order_messenger_preview.rb @@ -0,0 +1,17 @@ +class OrderMessengerPreview < Smswire::Preview + def shipped + OrderMessenger.with(order_number: "R100").shipped(Struct.new(:name, :phone_number).new("Ada", "+14155552671")) + end + + def unicode + OrderMessenger.literal("+14155552671", "Café ☕ is ready") + end + + def long + OrderMessenger.literal("+14155552671", "x" * 200) + end + + def skipped + OrderMessenger.maybe("+14155552671", send: false) + end +end diff --git a/test/generators_test.rb b/test/generators_test.rb new file mode 100644 index 0000000..fea0806 --- /dev/null +++ b/test/generators_test.rb @@ -0,0 +1,109 @@ +require "test_helper" +require "rails/generators" +require "rails/generators/test_case" +require "generators/smswire/install/install_generator" +require "generators/smswire/messenger/messenger_generator" +require "generators/smswire/provider/provider_generator" + +class InstallGeneratorTest < Rails::Generators::TestCase + tests Smswire::Generators::InstallGenerator + destination File.expand_path("../tmp/generators/install", __dir__) + + setup do + prepare_destination + FileUtils.mkdir_p(File.join(destination_root, "config")) + File.write(File.join(destination_root, "config/routes.rb"), "Rails.application.routes.draw do\nend\n") + end + + test "installs migrations, initializer, application messenger, and the mount" do + run_generator + + assert_migration "db/migrate/create_smswire_deliveries.smswire.rb" + assert_migration "db/migrate/create_smswire_consents_and_inbound_messages.smswire.rb" + assert_file "config/initializers/smswire.rb", /Smswire.configure do \|config\|/ + assert_file "app/messengers/application_messenger.rb", /class ApplicationMessenger < Smswire::Base/ + assert_file "config/routes.rb", %r{mount Smswire::Engine => "/smswire"} + end + + test "running twice does not duplicate migrations" do + 2.times { run_generator } + assert_equal 2, Dir[File.join(destination_root, "db/migrate/*.rb")].size + end +end + +class MessengerGeneratorTest < Rails::Generators::TestCase + tests Smswire::Generators::MessengerGenerator + destination File.expand_path("../tmp/generators/messenger", __dir__) + setup :prepare_destination + + test "creates the messenger, templates, preview, and test" do + run_generator %w[Order shipped delivered] + + assert_file "app/messengers/application_messenger.rb" + assert_file "app/messengers/order_messenger.rb" do |content| + assert_match(/class OrderMessenger < ApplicationMessenger/, content) + assert_match(/def shipped\(recipient\)/, content) + assert_match(/def delivered\(recipient\)/, content) + end + assert_file "app/views/order_messenger/shipped.text.erb", /<%= @greeting %>, this is OrderMessenger#shipped./ + assert_file "test/messengers/previews/order_messenger_preview.rb", /class OrderMessengerPreview < Smswire::Preview/ + assert_file "test/messengers/order_messenger_test.rb", /assert_sms_delivered/ + end + + test "accepts a name that already ends in Messenger" do + run_generator %w[OrderMessenger shipped] + assert_file "app/messengers/order_messenger.rb", /class OrderMessenger < ApplicationMessenger/ + end +end + +class ProviderGeneratorTest < Rails::Generators::TestCase + tests Smswire::Generators::ProviderGenerator + destination File.expand_path("../tmp/generators/provider", __dir__) + + setup do + prepare_destination + FileUtils.mkdir_p(File.join(destination_root, "config/initializers")) + File.write(File.join(destination_root, "config/initializers/smswire.rb"), "Smswire.configure { |config| }\n") + end + + test "creates an adapter skeleton and registers it" do + run_generator %w[Acme] + assert_file "app/sms_providers/acme_provider.rb", /class AcmeProvider < Smswire::Providers::Base/ + assert_file "test/sms_providers/acme_provider_test.rb", /class AcmeProviderTest/ + assert_file "config/initializers/smswire.rb", /Smswire::Providers.register\(:acme, "AcmeProvider"\)/ + end +end + +# Phase 4 acceptance: generated messenger output renders in previews with +# correct segment and encoding display. +class GeneratedMessengerPreviewTest < Smswire::IntegrationTest + ROOT = File.expand_path("../tmp/generators/acceptance", __dir__) + + # Generate once per run, then load the output like an app would. + def self.generate! + return if defined?(::WelcomeMessenger) + + FileUtils.rm_rf(ROOT) + Smswire::Generators::MessengerGenerator.new(%w[Welcome greet], {}, destination_root: ROOT).invoke_all + require File.join(ROOT, "app/messengers/welcome_messenger.rb") + require File.join(ROOT, "test/messengers/previews/welcome_messenger_preview.rb") + ::WelcomeMessenger.prepend_view_path(File.join(ROOT, "app/views")) + end + + setup { capture_io { self.class.generate! } } + + test "the generated preview renders with segment and encoding details" do + get "/smswire/previews" + assert_select "a[href='/smswire/previews/welcome_messenger/greet']" + + get "/smswire/previews/welcome_messenger/greet" + assert_response :ok + assert_select ".bubble", text: "Hi, this is WelcomeMessenger#greet." + assert_select "[data-encoding]", text: "GSM-7" + assert_select "[data-segments]", text: "1 segment" + end + + test "the generated messenger delivers through the pipeline" do + assert WelcomeMessenger.greet("+14155552671").deliver_now.accepted? + end +end diff --git a/test/inbound_test.rb b/test/inbound_test.rb index fbc14c8..b234b4e 100644 --- a/test/inbound_test.rb +++ b/test/inbound_test.rb @@ -1,15 +1,11 @@ require "test_helper" require "base64" -class InboundTest < ActionDispatch::IntegrationTest - include Smswire::TestHelper - include ConfigHelpers - +class InboundTest < Smswire::IntegrationTest PHONE = "+14155552671" OUR_NUMBER = "+15005550006" setup do - [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) stub_request(:post, twilio_url).to_return(twilio_success) end diff --git a/test/status_callbacks_test.rb b/test/status_callbacks_test.rb index 34ecdab..73fab16 100644 --- a/test/status_callbacks_test.rb +++ b/test/status_callbacks_test.rb @@ -1,12 +1,7 @@ require "test_helper" require "base64" -class StatusCallbacksTest < ActionDispatch::IntegrationTest - include Smswire::TestHelper - include ConfigHelpers - - setup { [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) } - +class StatusCallbacksTest < Smswire::IntegrationTest def sign(url, params, token: "secret") data = url + params.sort.map { |key, value| "#{key}#{value}" }.join Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", token, data)) diff --git a/test/test_helper.rb b/test/test_helper.rb index b2572d2..dfd8df6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,12 @@ ENV["RAILS_ENV"] = "test" +# A fresh file database each run; an in-memory database is lost whenever +# Active Record opens a second connection. +require "fileutils" +dummy_tmp = File.expand_path("dummy/tmp", __dir__) +FileUtils.mkdir_p(dummy_tmp) +Dir[File.join(dummy_tmp, "test.sqlite3*")].each { |file| File.delete(file) } + require_relative "dummy/config/environment" ActiveRecord::Migration.verbose = false @@ -32,9 +39,22 @@ def twilio_success(sid: "SM123", status: "queued", segments: "1") end end +# Every test starts with empty Smswire tables. +module DatabaseReset + def before_setup + [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) + super + end +end + class Smswire::TestCase < ActiveSupport::TestCase include Smswire::TestHelper include ConfigHelpers + include DatabaseReset +end - setup { [Smswire::Delivery, Smswire::Consent, Smswire::InboundMessage].each(&:delete_all) } +class Smswire::IntegrationTest < ActionDispatch::IntegrationTest + include Smswire::TestHelper + include ConfigHelpers + include DatabaseReset end