data_porter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/blog-status.md +10 -0
  3. data/.claude/commands/blog.md +109 -0
  4. data/.claude/commands/task-done.md +27 -0
  5. data/.claude/commands/tm/add-dependency.md +58 -0
  6. data/.claude/commands/tm/add-subtask.md +79 -0
  7. data/.claude/commands/tm/add-task.md +81 -0
  8. data/.claude/commands/tm/analyze-complexity.md +124 -0
  9. data/.claude/commands/tm/analyze-project.md +100 -0
  10. data/.claude/commands/tm/auto-implement-tasks.md +100 -0
  11. data/.claude/commands/tm/command-pipeline.md +80 -0
  12. data/.claude/commands/tm/complexity-report.md +120 -0
  13. data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
  14. data/.claude/commands/tm/expand-all-tasks.md +52 -0
  15. data/.claude/commands/tm/expand-task.md +52 -0
  16. data/.claude/commands/tm/fix-dependencies.md +82 -0
  17. data/.claude/commands/tm/help.md +101 -0
  18. data/.claude/commands/tm/init-project-quick.md +49 -0
  19. data/.claude/commands/tm/init-project.md +53 -0
  20. data/.claude/commands/tm/install-taskmaster.md +118 -0
  21. data/.claude/commands/tm/learn.md +106 -0
  22. data/.claude/commands/tm/list-tasks-by-status.md +42 -0
  23. data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
  24. data/.claude/commands/tm/list-tasks.md +46 -0
  25. data/.claude/commands/tm/next-task.md +69 -0
  26. data/.claude/commands/tm/parse-prd-with-research.md +51 -0
  27. data/.claude/commands/tm/parse-prd.md +52 -0
  28. data/.claude/commands/tm/project-status.md +67 -0
  29. data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
  30. data/.claude/commands/tm/remove-all-subtasks.md +94 -0
  31. data/.claude/commands/tm/remove-dependency.md +65 -0
  32. data/.claude/commands/tm/remove-subtask.md +87 -0
  33. data/.claude/commands/tm/remove-subtasks.md +89 -0
  34. data/.claude/commands/tm/remove-task.md +110 -0
  35. data/.claude/commands/tm/setup-models.md +52 -0
  36. data/.claude/commands/tm/show-task.md +85 -0
  37. data/.claude/commands/tm/smart-workflow.md +58 -0
  38. data/.claude/commands/tm/sync-readme.md +120 -0
  39. data/.claude/commands/tm/tm-main.md +147 -0
  40. data/.claude/commands/tm/to-cancelled.md +58 -0
  41. data/.claude/commands/tm/to-deferred.md +50 -0
  42. data/.claude/commands/tm/to-done.md +47 -0
  43. data/.claude/commands/tm/to-in-progress.md +39 -0
  44. data/.claude/commands/tm/to-pending.md +35 -0
  45. data/.claude/commands/tm/to-review.md +43 -0
  46. data/.claude/commands/tm/update-single-task.md +122 -0
  47. data/.claude/commands/tm/update-task.md +75 -0
  48. data/.claude/commands/tm/update-tasks-from-id.md +111 -0
  49. data/.claude/commands/tm/validate-dependencies.md +72 -0
  50. data/.claude/commands/tm/view-models.md +52 -0
  51. data/.env.example +12 -0
  52. data/.mcp.json +24 -0
  53. data/.taskmaster/CLAUDE.md +435 -0
  54. data/.taskmaster/config.json +44 -0
  55. data/.taskmaster/docs/prd.txt +2044 -0
  56. data/.taskmaster/state.json +6 -0
  57. data/.taskmaster/tasks/task_001.md +19 -0
  58. data/.taskmaster/tasks/task_002.md +19 -0
  59. data/.taskmaster/tasks/task_003.md +19 -0
  60. data/.taskmaster/tasks/task_004.md +19 -0
  61. data/.taskmaster/tasks/task_005.md +19 -0
  62. data/.taskmaster/tasks/task_006.md +19 -0
  63. data/.taskmaster/tasks/task_007.md +19 -0
  64. data/.taskmaster/tasks/task_008.md +19 -0
  65. data/.taskmaster/tasks/task_009.md +19 -0
  66. data/.taskmaster/tasks/task_010.md +19 -0
  67. data/.taskmaster/tasks/task_011.md +19 -0
  68. data/.taskmaster/tasks/task_012.md +19 -0
  69. data/.taskmaster/tasks/task_013.md +19 -0
  70. data/.taskmaster/tasks/task_014.md +19 -0
  71. data/.taskmaster/tasks/task_015.md +19 -0
  72. data/.taskmaster/tasks/task_016.md +19 -0
  73. data/.taskmaster/tasks/task_017.md +19 -0
  74. data/.taskmaster/tasks/task_018.md +19 -0
  75. data/.taskmaster/tasks/task_019.md +19 -0
  76. data/.taskmaster/tasks/task_020.md +19 -0
  77. data/.taskmaster/tasks/tasks.json +299 -0
  78. data/.taskmaster/templates/example_prd.txt +47 -0
  79. data/.taskmaster/templates/example_prd_rpg.txt +511 -0
  80. data/CHANGELOG.md +29 -0
  81. data/CLAUDE.md +65 -0
  82. data/CODE_OF_CONDUCT.md +10 -0
  83. data/CONTRIBUTING.md +49 -0
  84. data/LICENSE +21 -0
  85. data/README.md +463 -0
  86. data/Rakefile +12 -0
  87. data/app/assets/stylesheets/data_porter/application.css +646 -0
  88. data/app/channels/data_porter/import_channel.rb +10 -0
  89. data/app/controllers/data_porter/imports_controller.rb +68 -0
  90. data/app/javascript/data_porter/progress_controller.js +33 -0
  91. data/app/jobs/data_porter/dry_run_job.rb +12 -0
  92. data/app/jobs/data_porter/import_job.rb +12 -0
  93. data/app/jobs/data_porter/parse_job.rb +12 -0
  94. data/app/models/data_porter/data_import.rb +49 -0
  95. data/app/views/data_porter/imports/index.html.erb +142 -0
  96. data/app/views/data_porter/imports/new.html.erb +88 -0
  97. data/app/views/data_porter/imports/show.html.erb +49 -0
  98. data/config/database.yml +3 -0
  99. data/config/routes.rb +12 -0
  100. data/docs/SPEC.md +2012 -0
  101. data/docs/UI.md +32 -0
  102. data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
  103. data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
  104. data/docs/blog/003-configuration-dsl.md +222 -0
  105. data/docs/blog/004-store-model-jsonb.md +237 -0
  106. data/docs/blog/005-target-dsl.md +284 -0
  107. data/docs/blog/006-parsing-csv-sources.md +300 -0
  108. data/docs/blog/007-orchestrator.md +247 -0
  109. data/docs/blog/008-actioncable-stimulus.md +376 -0
  110. data/docs/blog/009-phlex-ui-components.md +446 -0
  111. data/docs/blog/010-controllers-routing.md +374 -0
  112. data/docs/blog/011-generators.md +364 -0
  113. data/docs/blog/012-json-api-sources.md +323 -0
  114. data/docs/blog/013-testing-rails-engine.md +618 -0
  115. data/docs/blog/014-dry-run.md +307 -0
  116. data/docs/blog/015-publishing-retro.md +264 -0
  117. data/docs/blog/016-erb-view-templates.md +431 -0
  118. data/docs/blog/017-showcase-final-retro.md +220 -0
  119. data/docs/blog/BACKLOG.md +8 -0
  120. data/docs/blog/SERIES.md +154 -0
  121. data/docs/screenshots/index-with-previewing.jpg +0 -0
  122. data/docs/screenshots/index.jpg +0 -0
  123. data/docs/screenshots/modal-new-import.jpg +0 -0
  124. data/docs/screenshots/preview.jpg +0 -0
  125. data/lib/data_porter/broadcaster.rb +29 -0
  126. data/lib/data_porter/components/base.rb +10 -0
  127. data/lib/data_porter/components/failure_alert.rb +20 -0
  128. data/lib/data_porter/components/preview_table.rb +54 -0
  129. data/lib/data_porter/components/progress_bar.rb +33 -0
  130. data/lib/data_porter/components/results_summary.rb +19 -0
  131. data/lib/data_porter/components/status_badge.rb +16 -0
  132. data/lib/data_porter/components/summary_cards.rb +30 -0
  133. data/lib/data_porter/components.rb +14 -0
  134. data/lib/data_porter/configuration.rb +25 -0
  135. data/lib/data_porter/dsl/api_config.rb +25 -0
  136. data/lib/data_porter/dsl/column.rb +17 -0
  137. data/lib/data_porter/engine.rb +15 -0
  138. data/lib/data_porter/orchestrator.rb +141 -0
  139. data/lib/data_porter/record_validator.rb +32 -0
  140. data/lib/data_porter/registry.rb +33 -0
  141. data/lib/data_porter/sources/api.rb +49 -0
  142. data/lib/data_porter/sources/base.rb +35 -0
  143. data/lib/data_porter/sources/csv.rb +43 -0
  144. data/lib/data_porter/sources/json.rb +45 -0
  145. data/lib/data_porter/sources.rb +20 -0
  146. data/lib/data_porter/store_models/error.rb +13 -0
  147. data/lib/data_porter/store_models/import_record.rb +52 -0
  148. data/lib/data_porter/store_models/report.rb +21 -0
  149. data/lib/data_porter/target.rb +89 -0
  150. data/lib/data_porter/type_validator.rb +46 -0
  151. data/lib/data_porter/version.rb +5 -0
  152. data/lib/data_porter.rb +32 -0
  153. data/lib/generators/data_porter/install/install_generator.rb +33 -0
  154. data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
  155. data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
  156. data/lib/generators/data_porter/target/target_generator.rb +44 -0
  157. data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
  158. data/sig/data_porter.rbs +4 -0
  159. metadata +274 -0
@@ -0,0 +1,618 @@
1
+ ---
2
+ title: "Building DataPorter #13 -- Testing a Rails Engine with RSpec"
3
+ series: "Building DataPorter - A Data Import Engine for Rails"
4
+ part: 13
5
+ tags: [ruby, rails, rails-engine, gem-development, rspec, testing, tdd]
6
+ published: false
7
+ ---
8
+
9
+ # Testing a Rails Engine with RSpec
10
+
11
+ > How to test a mountable Rails engine without a full Rails app -- using an in-memory SQLite database, stub controllers, anonymous target classes, and a spec_helper that bootstraps just enough Rails to exercise ActiveRecord, StoreModel, Phlex, generators, and even JavaScript files.
12
+
13
+ ## Context
14
+
15
+ This is part 13 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 12](#), we extended the Source layer to support JSON files and API endpoints.
16
+
17
+ We have been writing specs throughout the series, but we never stepped back to explain *how* the test suite works. A Rails engine is not a Rails app. There is no `config/database.yml`. There is no `spec/rails_helper.rb` generated by `rspec-rails`. There is no schema to load, no test database to create, no `ApplicationController` to inherit from. Every piece of infrastructure that a typical Rails project gives you for free -- the database connection, the load paths, the base controller class -- has to be set up manually in a gem's test suite.
18
+
19
+ In this article, we look at the full testing strategy: the spec_helper that bootstraps a minimal Rails environment, the patterns we use for each layer (models, controllers, components, generators, JavaScript), and the TDD workflow that shaped the series.
20
+
21
+ ## The problem
22
+
23
+ A Rails engine gem ships as a library. It has no `bin/rails`, no `config/application.rb`, no running server. When you run `rspec` inside the gem, Ruby loads your `spec_helper.rb` and your spec files -- and that is it. If your code depends on ActiveRecord, you need a database. If it depends on ActionController, you need a controller base class. If it depends on load paths for `app/models` or `app/controllers`, you need to set those up yourself.
24
+
25
+ The conventional solution is a "dummy app" -- a minimal Rails application inside `spec/dummy/` that boots the full Rails stack, mounts the engine, and provides the infrastructure specs need. This works, but it comes with overhead: you maintain a separate `Gemfile.lock`, a database config, an application class, route files, and all the boilerplate of a Rails app that exists only for tests. When the engine is small and focused, that overhead feels disproportionate.
26
+
27
+ DataPorter takes a different approach: no dummy app. The `spec_helper.rb` bootstraps just enough Rails to make the specs work, and nothing more. No application class. No route loading. No middleware stack. Just the frameworks we actually use -- ActiveRecord, ActiveJob, ActionController, ActionCable -- configured at the minimum viable level.
28
+
29
+ ## What we're building
30
+
31
+ Here is the spec_helper that powers the entire test suite:
32
+
33
+ ```ruby
34
+ # spec/spec_helper.rb
35
+ require "rails"
36
+ require "active_record"
37
+ require "active_job"
38
+ require "action_controller"
39
+ require "action_cable"
40
+ require "data_porter"
41
+
42
+ ActiveRecord::Base.establish_connection(
43
+ adapter: "sqlite3",
44
+ database: ":memory:"
45
+ )
46
+
47
+ ActiveRecord::Schema.define do
48
+ create_table :data_porter_imports, force: true do |t|
49
+ t.string :target_key, null: false, default: ""
50
+ t.string :source_type, null: false, default: "csv"
51
+ t.integer :status, null: false, default: 0
52
+ t.text :records
53
+ t.text :report
54
+ t.text :config
55
+
56
+ t.string :user_type
57
+ t.integer :user_id
58
+
59
+ t.timestamps
60
+ end
61
+ end
62
+
63
+ %w[models jobs].each do |dir|
64
+ $LOAD_PATH.unshift File.expand_path("../app/#{dir}", __dir__)
65
+ end
66
+ require "data_porter/data_import"
67
+ require "data_porter/parse_job"
68
+ require "data_porter/import_job"
69
+
70
+ # Stub for controller inheritance in test context
71
+ class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
72
+
73
+ $LOAD_PATH.unshift File.expand_path("../app/controllers", __dir__)
74
+ require "data_porter/imports_controller"
75
+
76
+ $LOAD_PATH.unshift File.expand_path("../app/channels", __dir__)
77
+ require "data_porter/import_channel"
78
+
79
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
80
+
81
+ RSpec.configure do |config|
82
+ config.example_status_persistence_file_path = ".rspec_status"
83
+ config.disable_monkey_patching!
84
+
85
+ config.expect_with :rspec do |c|
86
+ c.syntax = :expect
87
+ end
88
+ end
89
+ ```
90
+
91
+ Sixty lines of setup that replace a 200-file dummy app. Every line exists because a spec somewhere needs it.
92
+
93
+ ## Implementation
94
+
95
+ ### Step 1 -- The in-memory database
96
+
97
+ The first problem is the database. DataPorter's `DataImport` model is an ActiveRecord class that needs a real table with real columns. In a Rails app, you would run `rails db:create db:migrate`. In a gem, there is no migration runner. We need to create the schema from scratch every time the specs run.
98
+
99
+ SQLite's `:memory:` database solves this perfectly:
100
+
101
+ ```ruby
102
+ ActiveRecord::Base.establish_connection(
103
+ adapter: "sqlite3",
104
+ database: ":memory:"
105
+ )
106
+
107
+ ActiveRecord::Schema.define do
108
+ create_table :data_porter_imports, force: true do |t|
109
+ t.string :target_key, null: false, default: ""
110
+ t.string :source_type, null: false, default: "csv"
111
+ t.integer :status, null: false, default: 0
112
+ t.text :records
113
+ t.text :report
114
+ t.text :config
115
+ t.string :user_type
116
+ t.integer :user_id
117
+ t.timestamps
118
+ end
119
+ end
120
+ ```
121
+
122
+ The `establish_connection` call creates a SQLite database that lives entirely in memory -- no file on disk, no cleanup needed, no state leaking between test runs. The `Schema.define` block creates the table immediately. By the time the first spec runs, the database exists with the right schema.
123
+
124
+ The schema here mirrors the migration that the install generator creates for host apps, but it is defined inline rather than loaded from a migration file. This is a deliberate duplication: the spec_helper schema is the *test contract*, not the migration itself. If the migration changes and we forget to update the spec_helper, a spec will fail -- which is exactly the signal we want.
125
+
126
+ The `text` columns for `records`, `report`, and `config` are where StoreModel does its work. In production with PostgreSQL, these would be `jsonb` columns. In the test suite with SQLite, they are `text` columns -- and StoreModel handles the serialization transparently in both cases. This is one of the advantages of StoreModel over raw JSONB: the serialization adapter abstracts the column type difference.
127
+
128
+ ### Step 2 -- Load paths and the ApplicationController stub
129
+
130
+ A Rails engine's code lives in `app/models`, `app/controllers`, `app/jobs`, and `app/channels`. In a running Rails app, the autoloader resolves these paths. In a gem's test suite, there is no autoloader. We add the directories to `$LOAD_PATH` manually:
131
+
132
+ ```ruby
133
+ %w[models jobs].each do |dir|
134
+ $LOAD_PATH.unshift File.expand_path("../app/#{dir}", __dir__)
135
+ end
136
+ require "data_porter/data_import"
137
+ require "data_porter/parse_job"
138
+ require "data_porter/import_job"
139
+ ```
140
+
141
+ This is straightforward but easy to forget: without the `$LOAD_PATH` manipulation, `require "data_porter/data_import"` would fail because Ruby does not know to look in `app/models`.
142
+
143
+ The controller layer is trickier. `ImportsController` inherits from the host app's `ApplicationController` (or whatever `DataPorter.configuration.parent_controller` is set to). In a host Rails app, that class already exists. In the gem's test suite, it does not. We stub it:
144
+
145
+ ```ruby
146
+ class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
147
+ ```
148
+
149
+ The `unless defined?` guard is important. If something else in the test environment has already defined `ApplicationController` (which happens in some CI setups), we do not want to redefine it. The stub gives us just enough to verify that `ImportsController` is loadable, inherits from the right parent, and defines the expected methods. It does not give us routing or request processing -- but we do not need those for the kind of controller testing we do, which we will cover shortly.
150
+
151
+ ### Step 3 -- Testing ActiveRecord models with StoreModel
152
+
153
+ The `DataImport` model is a standard ActiveRecord class that uses StoreModel for its `records`, `report`, and `config` attributes. Testing it requires a working database (which we set up in step 1) and the StoreModel gem loaded (which happens through the `require "data_porter"` in the spec_helper).
154
+
155
+ The model specs test what any model spec would: validations, enum behavior, default values, and persistence. Here is the pattern:
156
+
157
+ ```ruby
158
+ # spec/data_porter/data_import_spec.rb
159
+ RSpec.describe DataPorter::DataImport, type: :model do
160
+ let(:target_class) do
161
+ Class.new(DataPorter::Target) do
162
+ label "Guests"
163
+ model_name "Guest"
164
+ icon "fas fa-users"
165
+ end
166
+ end
167
+
168
+ before do
169
+ DataPorter::Registry.clear
170
+ DataPorter::Registry.register(:guests, target_class)
171
+ end
172
+
173
+ describe "validations" do
174
+ it "requires target_key" do
175
+ import = described_class.new(source_type: "csv")
176
+
177
+ expect(import).not_to be_valid
178
+ expect(import.errors[:target_key]).to include("can't be blank")
179
+ end
180
+ end
181
+
182
+ describe "persistence" do
183
+ it "saves and reloads with records" do
184
+ import = described_class.create!(
185
+ target_key: "guests",
186
+ source_type: "csv",
187
+ user_type: "User",
188
+ user_id: 1
189
+ )
190
+ record = DataPorter::StoreModels::ImportRecord.new(
191
+ line_number: 1,
192
+ data: { name: "Alice" }
193
+ )
194
+ import.update!(records: [record])
195
+
196
+ reloaded = described_class.find(import.id)
197
+
198
+ expect(reloaded.records.first.line_number).to eq(1)
199
+ expect(reloaded.records.first.data).to eq({ "name" => "Alice" })
200
+ end
201
+ end
202
+ end
203
+ ```
204
+
205
+ Two things stand out. First, every spec that depends on a target class creates an **anonymous class** with `Class.new(DataPorter::Target)` and registers it in the `before` block. This avoids polluting the global namespace with named test classes and ensures each spec controls its own target definition. The `Registry.clear` in `before` prevents target registrations from one spec leaking into another.
206
+
207
+ Second, the persistence spec exercises the full StoreModel round-trip: create a DataImport, add ImportRecord objects to the `records` attribute, save, reload from the database, and verify the data survived serialization. This is critical because StoreModel serializes to JSON under the hood, and the round-trip through SQLite's `text` column can surface subtle issues -- symbol keys becoming string keys, for instance, which is exactly what `{ "name" => "Alice" }` (not `{ name: "Alice" }`) is testing.
208
+
209
+ ### Step 4 -- Testing StoreModel objects in isolation
210
+
211
+ The `ImportRecord` is a StoreModel type, not an ActiveRecord model. It has no table. It exists as a JSONB-backed object inside a `DataImport`'s `records` array. Testing it does not require a database at all:
212
+
213
+ ```ruby
214
+ # spec/data_porter/store_models/import_record_spec.rb
215
+ RSpec.describe DataPorter::StoreModels::ImportRecord do
216
+ subject(:record) { described_class.new(line_number: 1, data: { name: "Alice" }) }
217
+
218
+ describe "#add_error" do
219
+ it "appends an error to errors_list" do
220
+ record.add_error("name is required")
221
+
222
+ expect(record.errors_list.size).to eq(1)
223
+ expect(record.errors_list.first.message).to eq("name is required")
224
+ end
225
+ end
226
+
227
+ describe "#determine_status!" do
228
+ it "sets complete when no errors" do
229
+ record.determine_status!
230
+
231
+ expect(record.status).to eq("complete")
232
+ end
233
+
234
+ it "sets missing when required field error exists" do
235
+ record.add_error("Name is required")
236
+ record.determine_status!
237
+
238
+ expect(record.status).to eq("missing")
239
+ end
240
+ end
241
+
242
+ describe "#attributes" do
243
+ it "returns symbolized compact data" do
244
+ record = described_class.new(data: { "name" => "Alice", "email" => nil })
245
+
246
+ expect(record.attributes).to eq({ name: "Alice" })
247
+ end
248
+ end
249
+ end
250
+ ```
251
+
252
+ StoreModel objects behave like plain Ruby objects with attribute accessors -- you instantiate them with a hash, call methods on them, and assert on their state. No database queries, no fixtures, no setup. The `#attributes` spec is particularly important because it verifies the `compact` and `symbolize_keys` behavior that downstream code (the target's `persist` method) depends on. If `attributes` returned `nil` values or string keys, persisting to the host app's models would fail in subtle ways.
253
+
254
+ ### Step 5 -- Testing controllers structurally
255
+
256
+ In a full Rails app, you would test controllers with request specs: send a POST, check the response status, verify the redirect. In a gem without a dummy app, there is no router, no middleware stack, and no request processing. We cannot send HTTP requests because there is nowhere to send them.
257
+
258
+ Instead, we test controllers *structurally* -- verifying the class hierarchy, the callback registrations, and the method signatures:
259
+
260
+ ```ruby
261
+ # spec/data_porter/imports_controller_spec.rb
262
+ RSpec.describe DataPorter::ImportsController do
263
+ describe "inheritance" do
264
+ it "inherits from the configured parent controller" do
265
+ expect(described_class.superclass.name).to eq(
266
+ DataPorter.configuration.parent_controller
267
+ )
268
+ end
269
+ end
270
+
271
+ describe "before_actions" do
272
+ it "registers set_import callback" do
273
+ callbacks = described_class._process_action_callbacks.select do |c|
274
+ c.filter == :set_import
275
+ end
276
+
277
+ expect(callbacks).not_to be_empty
278
+ end
279
+ end
280
+
281
+ describe "action methods" do
282
+ it "defines index, new, create, show, parse, confirm, and cancel" do
283
+ actions = %i[index new create show parse confirm cancel]
284
+ actions.each do |action|
285
+ expect(described_class.instance_method(action)).to be_a(UnboundMethod)
286
+ end
287
+ end
288
+ end
289
+
290
+ describe "private methods" do
291
+ it "defines set_import" do
292
+ expect(described_class.private_instance_methods).to include(:set_import)
293
+ end
294
+
295
+ it "defines import_params" do
296
+ expect(described_class.private_instance_methods).to include(:import_params)
297
+ end
298
+ end
299
+ end
300
+ ```
301
+
302
+ This approach tests four things that matter at the gem level: (1) the controller inherits from the right parent class, which proves the dynamic inheritance mechanism works; (2) the `before_action` callbacks are registered, which proves the controller's lifecycle is set up correctly; (3) every expected action method exists as an instance method; (4) the private helper methods exist and are private.
303
+
304
+ What this does *not* test is the actual behavior of those actions -- what happens when you call `create` with certain params, or whether `show` renders the right component. Those are integration concerns that belong in the host app's test suite, or in a separate integration test suite with a dummy app. The gem-level specs verify the *contract* -- the controller exists, has the right shape, and plugs into the right place. The host app tests verify the *behavior*.
305
+
306
+ The `_process_action_callbacks` API is an internal Rails method that returns the registered callbacks. It is not a public API, but it is stable across Rails versions and it is the only way to verify callback registration without actually processing a request. Using it here is a pragmatic choice -- the alternative is a full request spec, which requires a dummy app.
307
+
308
+ ### Step 6 -- Testing Phlex components
309
+
310
+ Phlex components are the easiest layer to test in a gem context because they have zero Rails dependencies. A Phlex component is a Ruby object with a `call` method that returns an HTML string. No view context, no request, no controller:
311
+
312
+ ```ruby
313
+ # spec/data_porter/components/preview_table_spec.rb
314
+ RSpec.describe DataPorter::Components::PreviewTable do
315
+ def render(component)
316
+ component.call
317
+ end
318
+
319
+ let(:target_class) do
320
+ Class.new(DataPorter::Target) do
321
+ label "Guests"
322
+ model_name "Guest"
323
+
324
+ columns do
325
+ column :first_name, type: :string, required: true
326
+ column :last_name, type: :string
327
+ end
328
+ end
329
+ end
330
+
331
+ let(:record) do
332
+ DataPorter::StoreModels::ImportRecord.new(
333
+ line_number: 1,
334
+ status: "complete",
335
+ data: { "first_name" => "Alice", "last_name" => "Smith" }
336
+ )
337
+ end
338
+
339
+ let(:error_record) do
340
+ r = DataPorter::StoreModels::ImportRecord.new(
341
+ line_number: 2,
342
+ status: "missing",
343
+ data: { "first_name" => "", "last_name" => "Jones" }
344
+ )
345
+ r.add_error("First name is required")
346
+ r
347
+ end
348
+
349
+ it "renders column headers from target" do
350
+ html = render(described_class.new(columns: target_class._columns, records: [record]))
351
+
352
+ expect(html).to include("First name")
353
+ expect(html).to include("Last name")
354
+ end
355
+
356
+ it "renders error messages" do
357
+ html = render(described_class.new(columns: target_class._columns, records: [error_record]))
358
+
359
+ expect(html).to include("First name is required")
360
+ end
361
+
362
+ it "includes status-specific row class" do
363
+ html = render(described_class.new(columns: target_class._columns, records: [record]))
364
+ expect(html).to include("dp-row--complete")
365
+ end
366
+ end
367
+ ```
368
+
369
+ The local `render` helper is just `component.call` -- but giving it a name makes the specs read like a sentence: "render the component, expect the html to include this string." Every component spec follows this exact pattern.
370
+
371
+ The PreviewTable spec is the most interesting because it tests the full chain: the anonymous Target class declares columns via the DSL, those columns are passed to the component, and the rendered HTML includes the column labels and record data. This exercises the integration between the Target DSL and the view layer without touching a database.
372
+
373
+ The assertions are string-based (`include("Alice")`) rather than DOM-based (`have_css("td", text: "Alice")`). We do not use Capybara or Nokogiri for component specs. String matching is sufficient for verifying that the right content appears in the output, and it keeps the test dependencies minimal. For a gem that ships UI components, the host app's integration tests are the place for DOM-level assertions.
374
+
375
+ ### Step 7 -- Testing generators
376
+
377
+ Generator testing in a gem without a dummy app requires a different approach from the usual `rails generate` flow. We cannot run the generator and check the filesystem output because the generator expects a Rails app directory structure. Instead, we test the generator class itself -- its inheritance, its source root, and its method definitions:
378
+
379
+ ```ruby
380
+ # spec/data_porter/generators/install_generator_spec.rb
381
+ require "generators/data_porter/install/install_generator"
382
+
383
+ RSpec.describe DataPorter::Generators::InstallGenerator do
384
+ it "inherits from Rails::Generators::Base" do
385
+ expect(described_class.superclass).to eq(Rails::Generators::Base)
386
+ end
387
+
388
+ it "has a source_root pointing to templates" do
389
+ expect(described_class.source_root).to end_with(
390
+ "lib/generators/data_porter/install/templates"
391
+ )
392
+ end
393
+
394
+ describe "generator methods" do
395
+ it "defines copy_migration" do
396
+ expect(described_class.instance_method(:copy_migration)).to be_a(UnboundMethod)
397
+ end
398
+
399
+ it "defines copy_initializer" do
400
+ expect(described_class.instance_method(:copy_initializer)).to be_a(UnboundMethod)
401
+ end
402
+
403
+ it "defines create_importers_directory" do
404
+ expect(described_class.instance_method(:create_importers_directory)).to be_a(UnboundMethod)
405
+ end
406
+
407
+ it "defines mount_engine" do
408
+ expect(described_class.instance_method(:mount_engine)).to be_a(UnboundMethod)
409
+ end
410
+ end
411
+ end
412
+ ```
413
+
414
+ The `require` at the top loads the generator file directly from the `lib/generators` path -- it does not use the Rails generator lookup mechanism. The specs then verify three things: the generator inherits from the right base class (which determines what helpers are available), the `source_root` points to the right template directory (which determines where template files are found during generation), and every expected generator method exists (which determines what the generator actually does when invoked).
415
+
416
+ This is the same structural testing pattern we use for controllers. The specs prove the generator *exists and is wired correctly*. They do not prove that running `rails generate data_porter:install` produces the right files -- that would require a dummy app with a writable filesystem. But they catch the most common generator bugs: a misspelled method name, a wrong source root, or a missing base class.
417
+
418
+ ### Step 8 -- Testing JavaScript from Ruby specs
419
+
420
+ DataPorter ships a Stimulus controller in `app/javascript/data_porter/progress_controller.js`. In a typical frontend project, you would test this with Jest or Vitest, running the JavaScript in a Node.js environment. But DataPorter is a Ruby gem. Adding a JavaScript test runner means adding `package.json`, `node_modules`, and a separate CI step. For a single 30-line Stimulus controller, that is a lot of overhead.
421
+
422
+ Instead, we test the JavaScript file as a *text artifact* from Ruby:
423
+
424
+ ```ruby
425
+ # spec/data_porter/progress_controller_js_spec.rb
426
+ RSpec.describe "progress_controller.js" do
427
+ let(:js_path) do
428
+ File.expand_path("../../app/javascript/data_porter/progress_controller.js", __dir__)
429
+ end
430
+ let(:content) { File.read(js_path) }
431
+
432
+ it "exists" do
433
+ expect(File.exist?(js_path)).to be true
434
+ end
435
+
436
+ it "imports Stimulus Controller" do
437
+ expect(content).to include('import { Controller } from "@hotwired/stimulus"')
438
+ end
439
+
440
+ it "imports ActionCable consumer" do
441
+ expect(content).to include('import { createConsumer } from "@rails/actioncable"')
442
+ end
443
+
444
+ it "defines bar and text targets" do
445
+ expect(content).to include('static targets = ["bar", "text"]')
446
+ end
447
+
448
+ it "defines id value" do
449
+ expect(content).to include("static values = { id: Number }")
450
+ end
451
+
452
+ it "subscribes to ImportChannel on connect" do
453
+ expect(content).to include("DataPorter::ImportChannel")
454
+ end
455
+
456
+ it "unsubscribes on disconnect" do
457
+ expect(content).to include("this.subscription?.unsubscribe()")
458
+ end
459
+
460
+ it "updates progress bar width and text" do
461
+ expect(content).to include("this.barTarget.style.width")
462
+ expect(content).to include("this.textTarget.textContent")
463
+ end
464
+ end
465
+ ```
466
+
467
+ This reads the JavaScript file as a string and asserts that it contains the expected patterns: the right imports, the right Stimulus static declarations, the right channel subscription, the right DOM updates. It does not execute the JavaScript. It does not test that the progress bar actually animates. It tests the *contract* between the JavaScript file and the rest of the system.
468
+
469
+ This approach catches a specific class of bugs: someone renames the ActionCable channel in Ruby but forgets to update the JavaScript subscription string. Someone removes a Stimulus target from the HTML but leaves the target declaration in the controller. Someone refactors the progress update and forgets to update `barTarget.style.width`. These are integration seams where Ruby and JavaScript must agree, and string matching catches disagreements.
470
+
471
+ Is this a substitute for real JavaScript testing? No. But for a Ruby gem with a single Stimulus controller, it provides meaningful coverage without adding a JavaScript toolchain to the project. If the Stimulus controller grows more complex, the calculus changes and a proper JS test runner becomes worth the overhead.
472
+
473
+ ### Step 9 -- The anonymous target class pattern
474
+
475
+ A pattern that runs through nearly every spec file is the anonymous target class:
476
+
477
+ ```ruby
478
+ let(:target_class) do
479
+ Class.new(DataPorter::Target) do
480
+ label "Guests"
481
+ model_name "Guest"
482
+ icon "fas fa-users"
483
+ sources :csv
484
+
485
+ columns do
486
+ column :first_name, type: :string, required: true
487
+ column :last_name, type: :string
488
+ column :email, type: :email
489
+ end
490
+
491
+ csv_mapping do
492
+ map "First Name" => :first_name
493
+ map "Last Name" => :last_name
494
+ map "Email" => :email
495
+ end
496
+
497
+ define_method(:persist) do |record, **|
498
+ records << record.data
499
+ end
500
+ end
501
+ end
502
+ ```
503
+
504
+ This creates a new class that inherits from `DataPorter::Target` without giving it a constant name. Each spec defines exactly the target it needs -- the Orchestrator spec defines one with `persist`, the CSV spec defines one with `csv_mapping`, the PreviewTable spec defines one with just `columns`. No spec depends on a shared target fixture that might change.
505
+
506
+ The `define_method(:persist)` pattern (instead of `def persist`) is required because anonymous class blocks use `Class.new` with a block, and `def` inside that block defines a method on the class -- but if the method needs to capture a local variable (like `records` for the collection array), `define_method` with a closure is the only way. This is a Ruby metaprogramming detail that comes up often when testing DSL-heavy code.
507
+
508
+ The companion pattern is registry cleanup:
509
+
510
+ ```ruby
511
+ before do
512
+ DataPorter::Registry.clear
513
+ DataPorter::Registry.register(:guests, target_class)
514
+ end
515
+
516
+ after { DataPorter::Registry.clear }
517
+ ```
518
+
519
+ Every spec that registers a target clears the registry before and after. Without this, target registrations accumulate across specs, leading to flaky tests where the order of execution matters. The `clear` method on the Registry exists primarily for this testing use case.
520
+
521
+ ## Decisions & tradeoffs
522
+
523
+ | Decision | We chose | Over | Because |
524
+ |----------|----------|------|---------|
525
+ | Test infrastructure | Inline spec_helper with in-memory SQLite | Dummy Rails app in `spec/dummy/` | Less maintenance overhead, faster boot time, no separate Gemfile or config to maintain |
526
+ | Controller testing | Structural tests (inheritance, callbacks, method existence) | Request specs with a dummy app | Verifies the contract without requiring routing, middleware, or a full request cycle |
527
+ | Component testing | `component.call` with string assertions | Capybara or Nokogiri DOM assertions | Minimal dependencies; string matching is sufficient for verifying content in component output |
528
+ | Generator testing | Class-level structural tests | Running the generator against a temp directory | Catches wiring bugs without requiring a writable Rails app filesystem |
529
+ | JavaScript testing | Ruby string matching on file content | Jest/Vitest with a Node.js test runner | Avoids adding a JS toolchain for a single 30-line file; tests the Ruby/JS integration contract |
530
+ | Target fixtures | Anonymous classes per spec with `Class.new` | Shared named target classes | Each spec controls its own target definition; no coupling between specs |
531
+ | Registry isolation | `clear` before and after each spec that registers targets | Global shared state | Prevents registration leakage and ordering-dependent test failures |
532
+ | StoreModel testing | Direct instantiation without database | Round-trip through ActiveRecord | StoreModel objects are plain Ruby objects; database round-trips are tested separately on the DataImport model |
533
+
534
+ ## Testing it
535
+
536
+ The test suite itself is best verified by running it:
537
+
538
+ ```bash
539
+ $ bundle exec rspec
540
+
541
+ DataPorter::Configuration
542
+ has default parent_controller
543
+ has default queue_name
544
+ has default storage_service
545
+ ...
546
+
547
+ DataPorter::DataImport
548
+ validations
549
+ requires target_key
550
+ requires source_type
551
+ enum status
552
+ defaults to pending
553
+ persistence
554
+ saves and reloads with records
555
+
556
+ DataPorter::StoreModels::ImportRecord
557
+ #add_error
558
+ appends an error to errors_list
559
+ #determine_status!
560
+ sets complete when no errors
561
+ sets missing when required field error exists
562
+
563
+ DataPorter::Orchestrator
564
+ #parse!
565
+ transitions to previewing
566
+ creates import records from source data
567
+ validates required fields
568
+ #import!
569
+ transitions to completed
570
+ handles per-record errors
571
+
572
+ DataPorter::Components::PreviewTable
573
+ renders column headers from target
574
+ renders record data in rows
575
+ includes status-specific row class
576
+
577
+ DataPorter::Generators::InstallGenerator
578
+ inherits from Rails::Generators::Base
579
+ has a source_root pointing to templates
580
+
581
+ progress_controller.js
582
+ imports Stimulus Controller
583
+ defines bar and text targets
584
+ subscribes to ImportChannel on connect
585
+
586
+ Finished in 0.XXs
587
+ XX examples, 0 failures
588
+ ```
589
+
590
+ Every layer of the engine has coverage. The in-memory database starts and tears down in milliseconds. The full suite runs in under a second.
591
+
592
+ ## The TDD workflow
593
+
594
+ Throughout this series, every feature followed the same cycle: write a spec that describes the behavior we want, watch it fail, implement the minimum code to make it pass, then refactor. This is not a theoretical preference -- it is a practical necessity when building a gem.
595
+
596
+ In a Rails app, you can spike code in a controller action, hit refresh in the browser, and see if it works. In a gem, there is no browser. The spec suite *is* the feedback loop. When we built the Orchestrator in part 7, the first thing we wrote was a spec that called `orchestrator.parse!` and expected `data_import.status` to be `"previewing"`. When we built the PreviewTable in part 9, the first thing we wrote was a spec that rendered the component with two columns and expected the HTML to include both column headers.
597
+
598
+ The anonymous target class pattern emerged from this workflow. We needed a way to define targets inline in specs without creating named classes that would pollute the test namespace. `Class.new(DataPorter::Target)` was the answer, and it became the standard pattern for every spec that touches the target system.
599
+
600
+ The structural controller testing pattern also emerged from necessity. We needed to verify that `ImportsController` inherits from the configured parent controller, but we had no router to send requests through. Instead of building a dummy app just for controller tests, we tested the class structure directly. This turned out to be more valuable than request specs anyway, because the structural tests verify the *engine's contribution* -- the inheritance, the callbacks, the method definitions -- while leaving the *integration behavior* to the host app.
601
+
602
+ ## Recap
603
+
604
+ - **No dummy app**: the spec_helper bootstraps an in-memory SQLite database, sets up load paths, and stubs `ApplicationController` -- just enough Rails to test a full engine.
605
+ - **StoreModel objects** are tested as plain Ruby objects, with database round-trip tests on the parent ActiveRecord model.
606
+ - **Controllers** are tested structurally -- inheritance, callbacks, and method existence -- rather than with request specs, because the gem has no router.
607
+ - **Phlex components** are tested by calling `.call` and asserting against the HTML string, with no view context or browser.
608
+ - **Generators** are tested by verifying class hierarchy, source root, and method definitions -- the wiring, not the filesystem output.
609
+ - **JavaScript files** are tested as text artifacts from Ruby, verifying the contract between the JS and the rest of the engine.
610
+ - **Anonymous target classes** with `Class.new` keep each spec self-contained, and `Registry.clear` prevents state leakage.
611
+
612
+ ## Next up
613
+
614
+ Now that we have a tested, fully-featured engine, there is one more safety net to add before imports touch the database. In part 14, we build **Dry Run** -- a validation mode that wraps the import in a transaction, rolls it back, and enriches records with the database-level errors that the preview phase cannot catch. Preview validates the *data*; dry run validates the *persistence*.
615
+
616
+ ---
617
+
618
+ *This is part 13 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: Adding JSON & API Sources](#) | [Next: Dry Run: Validate Before You Persist](#)*