Make config.ru the composition root
Sentry.init, the access logger, and the contacts-dir mkdir all moved
from web.rb into config.ru, so requiring the app has no side effects
and tests need no DSN scrubbing or logger injection.

Assisted-by: GLM-5.3 via pi
change wwksknswzvuvqmqwvosustuplsyrrpup
commit b9429483f4e5e2cb64328e9b6f7c79ae82c2283f
author Alpha Chen <alpha@kejadlen.dev>
date
parent musrqwzz
diff --git a/Rakefile b/Rakefile
index 1d93de9..0ff7be3 100644
--- a/Rakefile
+++ b/Rakefile
@@ -19,11 +19,7 @@ desc "Regenerate macOS exchange response fixtures from current responses"
 task :fixtures do
   # Mirrors test/test_helper.rb, which cannot be required here without
   # minitest/autorun running its at_exit hook inside rake.
-  ENV.delete("SENTRY_DSN")
   ENV["PRO_TACTS_DATA_DIR"] = (Pathname.new(__dir__) / "test/fixtures").to_s
-  require "logger"
-  require "pro_tacts"
-  ProTacts.config.access_logger = Logger.new(IO::NULL)
   require "pro_tacts/web"
   require_relative "test/pro_tacts/exchange_fixtures"
   ExchangeFixtures.record_responses(ProTacts::Web)
diff --git a/config.ru b/config.ru
index e86a66d..8fa4d0f 100644
--- a/config.ru
+++ b/config.ru
@@ -1,9 +1,35 @@
-
+# The composition root: everything environment-dependent (data
+# directories, Sentry, the access log) happens here rather than at
+# require time, so requiring the app has no side effects.
 require "pathname"
+require "fileutils"
+require "sentry-ruby"
 
 $LOAD_PATH.unshift(Pathname.new(__dir__) / "lib")
 require "pro_tacts/web"
 
-ProTacts.ensure_data_directories
+config = ProTacts.config
+
+# A fresh checkout has no contacts dir; an empty address book beats a
+# 500 on every request.
+FileUtils.mkdir_p(config.contacts_dir)
+
+# A nil DSN initializes Sentry but leaves it inert: capture_message
+# returns nil and the rack middleware reports nothing.
+Sentry.init do |sentry|
+  sentry.dsn = config.sentry_dsn
+
+  # Get breadcrumbs from logs
+  sentry.breadcrumbs_logger = [:sentry_logger, :http_logger]
+
+  # Add data like request headers and IP for users, if applicable;
+  # see https://docs.sentry.io/platforms/ruby/data-management/data-collected/ for more info
+  sentry.send_default_pii = true
+
+  # Trace all the things!
+  sentry.traces_sample_rate = 1.0
+end
+
+ProTacts::Web.plugin :common_logger, $stderr
 
 run ProTacts::Web.freeze.app
diff --git a/lib/pro_tacts.rb b/lib/pro_tacts.rb
index 317deea..31381b8 100644
--- a/lib/pro_tacts.rb
+++ b/lib/pro_tacts.rb
@@ -1,6 +1,4 @@
 
-require "fileutils"
-
 require "pro_tacts/config"
 
 module ProTacts
@@ -10,12 +8,5 @@ module ProTacts
     end
 
     attr_writer :config
-
-    # Called from config.ru rather than at require time, so loading the
-    # app stays side-effect free. A fresh checkout has no contacts dir;
-    # an empty address book beats a 500 on every request.
-    def ensure_data_directories
-      FileUtils.mkdir_p(config.contacts_dir)
-    end
   end
 end
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index b85b7ca..e60c5bf 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -12,9 +12,9 @@ module ProTacts
       @env = env
     end
 
-    # Sentry DSN; nil when unset. Presence is the switch for Sentry:
-    # without it the app runs uninitialized, where capture_message and
-    # the rack middleware are no-ops.
+    # Sentry DSN; nil when unset. A nil DSN is passed straight to
+    # Sentry.init, which leaves the client inert — capture_message and
+    # the rack middleware become no-ops.
     def sentry_dsn
       @env.fetch("SENTRY_DSN", nil)
     end
@@ -39,10 +39,6 @@ module ProTacts
       data_dir / "contacts"
     end
 
-    # Override for Roda's common_logger target; nil keeps the plugin's
-    # stderr default. Tests swap in a null logger for quiet runs.
-    attr_accessor :access_logger
-
     # Where the debug logger writes. A path, overridable with
     # PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
     def debug_log_path
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index e83f671..1a22944 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -2,24 +2,6 @@
 require "pro_tacts"
 require "sentry-ruby"
 
-# Without a DSN, Sentry stays uninitialized and capture_message and
-# the rack middleware are no-ops, so there is nothing to skip in tests.
-if ProTacts.config.sentry_dsn
-  Sentry.init do |sentry|
-    sentry.dsn = ProTacts.config.sentry_dsn
-
-    # Get breadcrumbs from logs
-    sentry.breadcrumbs_logger = [:sentry_logger, :http_logger]
-
-    # Add data like request headers and IP for users, if applicable;
-    # see https://docs.sentry.io/platforms/ruby/data-management/data-collected/ for more info
-    sentry.send_default_pii = true
-
-    # Trace all the things!
-    sentry.traces_sample_rate = 1.0
-  end
-end
-
 require "rack/rewindable_input"
 require "nokogiri"
 require "roda"
@@ -41,7 +23,6 @@ module ProTacts
 
     plugin :all_verbs
     plugin :dav_verbs
-    plugin :common_logger, ProTacts.config.access_logger
 
     plugin :not_found do
       Sentry.capture_message("404 Not Found", level: :warning)
diff --git a/test/pro_tacts/test_config.rb b/test/pro_tacts/test_config.rb
index 4437bb7..a28daaa 100644
--- a/test/pro_tacts/test_config.rb
+++ b/test/pro_tacts/test_config.rb
@@ -1,6 +1,4 @@
 
-require "logger"
-
 require "minitest/autorun"
 
 require "pro_tacts/config"
@@ -44,19 +42,6 @@ class ConfigTest < Minitest::Test
     refute ProTacts::Config.new("PRO_TACTS_DEBUG" => "0").debug?
   end
 
-  def test_access_logger_defaults_to_nil
-    assert_nil ProTacts::Config.new({}).access_logger
-  end
-
-  def test_access_logger_is_overridable
-    config = ProTacts::Config.new({})
-    null_logger = Logger.new(IO::NULL)
-
-    config.access_logger = null_logger
-
-    assert_same null_logger, config.access_logger
-  end
-
   def test_debug_log_path_defaults_to_a_file
     assert_equal "log/debug.log", ProTacts::Config.new({}).debug_log_path
   end
diff --git a/test/pro_tacts/test_config_ru.rb b/test/pro_tacts/test_config_ru.rb
new file mode 100644
index 0000000..1c58c2c
--- /dev/null
+++ b/test/pro_tacts/test_config_ru.rb
@@ -0,0 +1,14 @@
+require "test_helper"
+
+# Rack reads config.ru with the process's default external encoding,
+# which is US-ASCII under a C locale; any non-ASCII byte in the file
+# crashes boot (seen with rack 3.2.4). Ordinary .rb files are safe
+# because Ruby parses source as UTF-8 regardless of locale.
+class ConfigRuTest < Minitest::Test
+  def test_config_ru_is_ascii_only
+    config_ru = File.expand_path("../../config.ru", __dir__)
+
+    assert File.read(config_ru).ascii_only?,
+           "config.ru must stay ASCII-only or boot crashes under a C locale"
+  end
+end
diff --git a/test/pro_tacts/test_pro_tacts.rb b/test/pro_tacts/test_pro_tacts.rb
deleted file mode 100644
index fcf002b..0000000
--- a/test/pro_tacts/test_pro_tacts.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "test_helper"
-
-require "tmpdir"
-
-class ProTactsTest < Minitest::Test
-  def teardown
-    # Reset the swapped-in config so later tests see the default one.
-    ProTacts.config = nil
-  end
-
-  def test_ensure_data_directories_creates_a_nested_contacts_dir
-    Dir.mktmpdir do |tmp|
-      data_dir = File.join(tmp, "nested", "data")
-      ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => data_dir)
-
-      ProTacts.ensure_data_directories
-
-      assert_path_exists File.join(data_dir, "contacts")
-    end
-  end
-
-  def test_ensure_data_directories_is_idempotent
-    Dir.mktmpdir do |tmp|
-      data_dir = File.join(tmp, "data")
-      ProTacts.config = ProTacts::Config.new("PRO_TACTS_DATA_DIR" => data_dir)
-
-      2.times { ProTacts.ensure_data_directories }
-
-      assert_path_exists File.join(data_dir, "contacts")
-    end
-  end
-end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 2ef8f9e..eca876f 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -1,18 +1,10 @@
 
-# Drops an ambient SENTRY_DSN (e.g. exported by direnv) before anything
-# requires the app, so Sentry stays uninitialized here and the 404 tests
-# cannot ship events. The fixtures directory serves as the data directory:
-# its contacts/ holds the card the recorded macOS exchange asked for, so
-# the replay resolves the same hrefs the client did.
-ENV.delete("SENTRY_DSN")
-
+# The fixtures directory serves as the data directory: its contacts/
+# holds the card the recorded macOS exchange asked for, so the replay
+# resolves the same hrefs the client did. Nothing else is set up:
+# requiring the app has no side effects, and config.ru never runs here.
 require "pathname"
 
 ENV["PRO_TACTS_DATA_DIR"] = (Pathname.new(__dir__) / "fixtures").to_s
 
-# Silence the request log; web.rb reads this when it is required.
-require "logger"
-require "pro_tacts"
-ProTacts.config.access_logger = Logger.new(IO::NULL)
-
 require "minitest/autorun"