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,374 @@
1
+ ---
2
+ title: "Building DataPorter #10 -- Controllers & Routing in a Rails Engine"
3
+ series: "Building DataPorter - A Data Import Engine for Rails"
4
+ part: 10
5
+ tags: [ruby, rails, rails-engine, gem-development, controllers, routing, strong-params]
6
+ published: false
7
+ ---
8
+
9
+ # Controllers & Routing in a Rails Engine
10
+
11
+ > Engine controllers are tricky -- they need to inherit from the host app, define routes that do not collide with anything else, and stay thin while coordinating an entire import workflow. Here is the clean way.
12
+
13
+ ## Context
14
+
15
+ This is part 10 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 9](#), we built the UI layer with Phlex components and scoped Tailwind styles, giving users a visual interface for previewing and managing imports.
16
+
17
+ But a UI needs a backend to drive it. We need controller actions that create imports, kick off background jobs, let users confirm or cancel, and route everything under a clean, isolated namespace. In this article, we build the ImportsController, wire up engine routes with member actions, and solve one of the trickiest problems in engine development: making the controller inherit from whichever parent class the host app wants.
18
+
19
+ ## The parent controller pattern
20
+
21
+ When you build a Rails engine, your controllers need to inherit from *something*. The obvious choice is `ActionController::Base`, but that is almost never what you want. Host apps typically have authentication, authorization, error handling, and layout logic on their own `ApplicationController` (or a dedicated `AdminController`). If your engine inherits from `ActionController::Base`, none of that flows through. The user would need to separately configure auth for every engine route -- a terrible experience.
22
+
23
+ The solution is dynamic inheritance. DataPorter's Configuration class exposes a `parent_controller` setting that defaults to `"ApplicationController"`:
24
+
25
+ ```ruby
26
+ # lib/data_porter/configuration.rb
27
+ class Configuration
28
+ attr_accessor :parent_controller,
29
+ :queue_name,
30
+ :storage_service,
31
+ :cable_channel_prefix,
32
+ :context_builder,
33
+ :preview_limit,
34
+ :enabled_sources,
35
+ :scope
36
+
37
+ def initialize
38
+ @parent_controller = "ApplicationController"
39
+ @queue_name = :imports
40
+ @storage_service = :local
41
+ @cable_channel_prefix = "data_porter"
42
+ @context_builder = nil
43
+ @preview_limit = 500
44
+ @enabled_sources = %i[csv json api]
45
+ @scope = nil
46
+ end
47
+ end
48
+ ```
49
+
50
+ The controller then uses `constantize` at class definition time to resolve the string into a class:
51
+
52
+ ```ruby
53
+ # app/controllers/data_porter/imports_controller.rb
54
+ module DataPorter
55
+ class ImportsController < DataPorter.configuration.parent_controller.constantize
56
+ ```
57
+
58
+ This single line is doing a lot. When Ruby loads the controller file, it evaluates the superclass expression. `DataPorter.configuration.parent_controller` returns a string like `"ApplicationController"` or `"Admin::BaseController"`, and `.constantize` turns it into the actual class. The result is that `ImportsController` inherits from whatever the host app configured -- picking up its before_actions, authentication, layouts, and helper methods automatically.
59
+
60
+ A host app that wants all DataPorter routes behind admin authentication just needs:
61
+
62
+ ```ruby
63
+ # config/initializers/data_porter.rb
64
+ DataPorter.configure do |config|
65
+ config.parent_controller = "Admin::BaseController"
66
+ end
67
+ ```
68
+
69
+ No monkey-patching, no middleware, no route constraints. The engine's controller *is* an admin controller.
70
+
71
+ The tradeoff is that this happens once, at load time. If you change the configuration after the controller class has been loaded, the inheritance does not update. In practice this is not an issue -- initializers run before controllers are loaded in production, and in development Rails reloads everything per-request anyway. But it is worth knowing: the parent controller is resolved eagerly, not lazily.
72
+
73
+ ## Engine routes
74
+
75
+ DataPorter draws its routes inside the engine's own router, completely isolated from the host app:
76
+
77
+ ```ruby
78
+ # config/routes.rb
79
+ DataPorter::Engine.routes.draw do
80
+ resources :imports, only: %i[index new create show] do
81
+ member do
82
+ post :parse
83
+ post :confirm
84
+ post :cancel
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
90
+ The host app mounts the engine at whatever path it wants:
91
+
92
+ ```ruby
93
+ # Host app: config/routes.rb
94
+ mount DataPorter::Engine, at: "/data_porter"
95
+ ```
96
+
97
+ This produces the following route table:
98
+
99
+ | HTTP method | Path | Action | Purpose |
100
+ |-------------|------|--------|---------|
101
+ | GET | `/data_porter/imports` | `index` | List all imports |
102
+ | GET | `/data_porter/imports/new` | `new` | New import form |
103
+ | POST | `/data_porter/imports` | `create` | Create and start parsing |
104
+ | GET | `/data_porter/imports/:id` | `show` | Preview/status page |
105
+ | POST | `/data_porter/imports/:id/parse` | `parse` | Re-parse an import |
106
+ | POST | `/data_porter/imports/:id/confirm` | `confirm` | Confirm and start importing |
107
+ | POST | `/data_porter/imports/:id/cancel` | `cancel` | Cancel an import |
108
+
109
+ Notice what is *not* here: `edit`, `update`, `destroy`. An import is not a CRUD resource in the traditional sense. You create it, you preview it, you confirm or cancel it. There is no "edit an import" workflow -- if the data is wrong, you cancel and create a new one. The routes reflect this by using `only:` to restrict the standard REST actions and adding three custom member routes for the state-transition actions.
110
+
111
+ The member routes are all `post`, not `patch` or `put`. These are command actions that trigger side effects (enqueue a job, change status), not updates to a resource's attributes. POST is semantically correct here and plays well with Turbo, which sends POST requests from `button_to` helpers by default.
112
+
113
+ Because `isolate_namespace` is set on the engine, all routes are automatically scoped. Inside the controller, you use `import_path(@import)` and `imports_path` -- no prefix needed. The engine's router handles the namespace. This also means the host app's own `imports_path` (if it has one) is completely separate.
114
+
115
+ ## Each action explained
116
+
117
+ Here is the full controller, then we will walk through each action:
118
+
119
+ ```ruby
120
+ # app/controllers/data_porter/imports_controller.rb
121
+ module DataPorter
122
+ class ImportsController < DataPorter.configuration.parent_controller.constantize
123
+ before_action :set_import, only: %i[show parse confirm cancel]
124
+
125
+ def index
126
+ @imports = DataPorter::DataImport.order(created_at: :desc)
127
+ end
128
+
129
+ def new
130
+ @import = DataPorter::DataImport.new
131
+ @targets = DataPorter::Registry.available
132
+ end
133
+
134
+ def create
135
+ @import = DataPorter::DataImport.new(import_params)
136
+ @import.user = current_user if respond_to?(:current_user, true)
137
+ @import.status = :pending
138
+
139
+ if @import.save
140
+ DataPorter::ParseJob.perform_later(@import.id)
141
+ redirect_to import_path(@import)
142
+ else
143
+ @targets = DataPorter::Registry.available
144
+ render :new
145
+ end
146
+ end
147
+
148
+ def show
149
+ @target = @import.target_class
150
+ @records = @import.records
151
+ @grouped = @records.group_by(&:status)
152
+ end
153
+
154
+ def parse
155
+ @import.update!(status: :pending)
156
+ DataPorter::ParseJob.perform_later(@import.id)
157
+ redirect_to import_path(@import)
158
+ end
159
+
160
+ def confirm
161
+ DataPorter::ImportJob.perform_later(@import.id)
162
+ redirect_to import_path(@import)
163
+ end
164
+
165
+ def cancel
166
+ @import.update!(status: :failed)
167
+ redirect_to imports_path
168
+ end
169
+
170
+ private
171
+
172
+ def set_import
173
+ @import = DataPorter::DataImport.find(params[:id])
174
+ end
175
+
176
+ def import_params
177
+ params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
178
+ end
179
+ end
180
+ end
181
+ ```
182
+
183
+ **`index`** -- Lists all imports, newest first. No pagination yet (that is a future enhancement), but the query is simple enough that the view layer can handle it. This is the dashboard view.
184
+
185
+ **`new`** -- Builds a blank DataImport and loads the available targets from the Registry. The view uses these to render a dropdown of import types. This is where the Target DSL pays off: each registered target provides its own label and icon, and the controller just passes the list through.
186
+
187
+ **`create`** -- The most interesting action. It builds the import from strong params, optionally assigns the current user (using `respond_to?(:current_user, true)` to avoid crashing if the host app has no authentication), sets the initial status, and saves. On success, it immediately enqueues a `ParseJob` and redirects to the show page where the user will see real-time progress. On failure, it reloads the targets and re-renders the form.
188
+
189
+ The `respond_to?(:current_user, true)` check deserves attention. The second argument (`true`) includes private methods in the check. Some authentication gems define `current_user` as a private helper. By passing `true`, we catch both public and private definitions. If the host app has no authentication at all, the check returns false and the user association is simply not set. The import still works -- `user` is a polymorphic optional association.
190
+
191
+ **`show`** -- Loads the target class, the records, and groups them by status. The view uses this grouping to show separate sections for complete, partial, and missing records. This is the preview page when the import is in `previewing` status, and the results page when it is `completed`.
192
+
193
+ **`parse`** -- Re-parses an existing import. This resets the status to `pending` and enqueues a new ParseJob. Useful when the user realizes the CSV mapping was wrong and re-uploads a file, or when a previous parse failed and they want to retry.
194
+
195
+ **`confirm`** -- The user has reviewed the preview and decided to proceed. This enqueues an ImportJob and redirects back to the show page, where ActionCable will push real-time progress updates (as we built in part 8).
196
+
197
+ **`cancel`** -- Marks the import as failed and redirects to the index. No job is enqueued, no further processing happens. The import is preserved in the database for auditing but is effectively dead.
198
+
199
+ ## Strong params
200
+
201
+ The `import_params` method is deliberately minimal:
202
+
203
+ ```ruby
204
+ def import_params
205
+ params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
206
+ end
207
+ ```
208
+
209
+ Four permitted fields:
210
+
211
+ - **`target_key`** -- A string like `"user_import"` that maps to a registered target. The Target DSL and Registry validate this downstream.
212
+ - **`source_type`** -- A string like `"csv"`, `"json"`, or `"api"`. Determines which Source class handles the data.
213
+ - **`file`** -- An ActiveStorage attachment. The uploaded CSV, JSON, or other file.
214
+ - **`config`** -- A hash for source-specific configuration (API endpoints, headers, JSON root paths). The `config: {}` syntax permits any keys inside the hash, which is intentional: different source types need different config keys, and we validate them at the Source level rather than the controller level.
215
+
216
+ Notably absent: `status`, `records`, `report`, `user_id`, `user_type`. These are all set internally -- status by the controller and orchestrator, records and report by the ParseJob, and user by the controller's `current_user` logic. Strong params prevents any of these from being injected through the form.
217
+
218
+ ## Decisions & tradeoffs
219
+
220
+ | Decision | We chose | Over | Because |
221
+ |----------|----------|------|---------|
222
+ | Parent controller | Dynamic inheritance via `constantize` | Hardcoded `ActionController::Base` | Host app authentication, layouts, and helpers flow through automatically; zero extra configuration for auth |
223
+ | Route design | `only:` with custom member actions | Full `resources` CRUD | Imports are not a traditional CRUD resource; explicit routes match the actual workflow (create, preview, confirm/cancel) |
224
+ | Member action verbs | All POST | PATCH for parse, DELETE for cancel | These are command actions with side effects, not attribute updates; POST is semantically correct and works naturally with Turbo's `button_to` |
225
+ | User assignment | `respond_to?(:current_user, true)` guard | Requiring authentication | The engine must work with and without authentication; optional user association keeps it flexible |
226
+ | Config params | `config: {}` (open hash) | Explicitly listing every config key | Different source types need different config keys; validation happens at the Source level where context exists |
227
+ | No edit/update/destroy | Omitted from routes entirely | Including them "just in case" | An import is an immutable workflow; if it is wrong, you cancel and start over; fewer routes means fewer security surfaces |
228
+
229
+ ## Testing it
230
+
231
+ Testing engine controllers without a full Rails app or a dummy app is the interesting challenge here. The approach we use: stub just enough of the Rails stack to load the controller class, then test its structure directly.
232
+
233
+ The spec helper handles the setup. It loads the minimal Rails dependencies, sets up an in-memory SQLite database, stubs `ApplicationController`, and manually requires the controller:
234
+
235
+ ```ruby
236
+ # spec/spec_helper.rb (relevant excerpt)
237
+ require "rails"
238
+ require "action_controller"
239
+
240
+ # Stub for controller inheritance in test context
241
+ class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
242
+
243
+ $LOAD_PATH.unshift File.expand_path("../app/controllers", __dir__)
244
+ require "data_porter/imports_controller"
245
+ ```
246
+
247
+ The `unless defined?(ApplicationController)` guard is important -- it prevents double-definition errors if the host app's ApplicationController has already been loaded (which happens in integration test suites).
248
+
249
+ With that foundation, the controller spec tests structure rather than HTTP behavior:
250
+
251
+ ```ruby
252
+ # spec/data_porter/imports_controller_spec.rb
253
+ RSpec.describe DataPorter::ImportsController do
254
+ let(:target_class) do
255
+ Class.new(DataPorter::Target) do
256
+ label "Test Import"
257
+ icon "fas fa-test"
258
+ model_name "TestModel"
259
+
260
+ columns do
261
+ column :name, type: :string, required: true
262
+ end
263
+ end
264
+ end
265
+
266
+ before do
267
+ DataPorter::Registry.clear
268
+ DataPorter::Registry.register(:test_import, target_class)
269
+ end
270
+
271
+ after { DataPorter::Registry.clear }
272
+
273
+ describe "inheritance" do
274
+ it "inherits from the configured parent controller" do
275
+ expect(described_class.superclass.name).to eq(DataPorter.configuration.parent_controller)
276
+ end
277
+ end
278
+
279
+ describe "before_actions" do
280
+ it "registers set_import callback" do
281
+ callbacks = described_class._process_action_callbacks.select do |c|
282
+ c.filter == :set_import
283
+ end
284
+
285
+ expect(callbacks).not_to be_empty
286
+ end
287
+ end
288
+
289
+ describe "action methods" do
290
+ it "defines index, new, create, show, parse, confirm, and cancel" do
291
+ actions = %i[index new create show parse confirm cancel]
292
+ actions.each do |action|
293
+ expect(described_class.instance_method(action)).to be_a(UnboundMethod)
294
+ end
295
+ end
296
+ end
297
+
298
+ describe "private methods" do
299
+ it "defines set_import" do
300
+ expect(described_class.private_instance_methods).to include(:set_import)
301
+ end
302
+
303
+ it "defines import_params" do
304
+ expect(described_class.private_instance_methods).to include(:import_params)
305
+ end
306
+ end
307
+ end
308
+ ```
309
+
310
+ There are a few things worth noting about this approach:
311
+
312
+ First, the tests verify *inheritance* rather than HTTP responses. The `"inherits from the configured parent controller"` spec confirms that the dynamic `constantize` pattern actually works and that the controller's superclass matches the configuration. If someone changes the inheritance line, this catches it.
313
+
314
+ Second, callback registration is tested by inspecting `_process_action_callbacks` directly. This is a Rails internal API, but it is stable and avoids the need to make real HTTP requests just to check that a before_action is wired up.
315
+
316
+ Third, the action methods spec iterates over all seven expected actions and verifies they exist as instance methods. This is a structural test -- it does not exercise the action logic, but it catches missing methods immediately.
317
+
318
+ The routes get their own spec that redraws the engine routes and inspects the route set:
319
+
320
+ ```ruby
321
+ # spec/data_porter/routes_spec.rb
322
+ RSpec.describe "DataPorter routes" do
323
+ before(:all) do
324
+ DataPorter::Engine.routes.draw do
325
+ resources :imports, only: %i[index new create show] do
326
+ member do
327
+ post :parse
328
+ post :confirm
329
+ post :cancel
330
+ end
331
+ end
332
+ end
333
+ end
334
+
335
+ it "defines import resource routes" do
336
+ routes = DataPorter::Engine.routes
337
+ route_set = routes.routes.map { |r| [r.defaults[:action], r.path.spec.to_s] }
338
+
339
+ expect(route_set).to include(["index", "/imports(.:format)"])
340
+ expect(route_set).to include(["new", "/imports/new(.:format)"])
341
+ expect(route_set).to include(["create", "/imports(.:format)"])
342
+ expect(route_set).to include(["show", "/imports/:id(.:format)"])
343
+ end
344
+
345
+ it "defines member routes for parse, confirm, and cancel" do
346
+ routes = DataPorter::Engine.routes
347
+ route_set = routes.routes.map { |r| [r.defaults[:action], r.path.spec.to_s] }
348
+
349
+ expect(route_set).to include(["parse", "/imports/:id/parse(.:format)"])
350
+ expect(route_set).to include(["confirm", "/imports/:id/confirm(.:format)"])
351
+ expect(route_set).to include(["cancel", "/imports/:id/cancel(.:format)"])
352
+ end
353
+ end
354
+ ```
355
+
356
+ The `before(:all)` block redraws the routes inside the test environment. This is necessary because the engine's routes file is loaded differently in test versus production. By explicitly drawing the routes, we guarantee the test is exercising the exact route definitions we ship, not whatever routes happened to be loaded by a host app. The specs then map each route to an `[action, path]` pair and verify all seven are present with the correct paths.
357
+
358
+ This style of testing -- structural rather than behavioral -- may feel unusual. But for an engine, it is the right level of abstraction. Full request specs belong in the host app's integration suite, where they can exercise the real authentication stack, the real database, and the real views. The engine's specs verify that the pieces are correctly defined and wired together.
359
+
360
+ ## Recap
361
+
362
+ - The **dynamic parent controller pattern** uses `constantize` to resolve the configured controller name at class-load time, allowing the engine to inherit authentication, layouts, and helpers from whatever base class the host app chooses.
363
+ - **Engine routes** are drawn inside `DataPorter::Engine.routes.draw`, keeping them isolated from the host app. The host mounts the engine at any path it wants.
364
+ - **Seven actions** map to a clean import workflow: list, create, preview, re-parse, confirm, cancel. No edit or destroy -- imports are immutable workflows.
365
+ - **Strong params** permit only the fields the user should control (`target_key`, `source_type`, `file`, `config`), while status, records, and user are set internally.
366
+ - **Controller specs** test structure (inheritance, callbacks, method existence) rather than HTTP behavior, avoiding the need for a full dummy app.
367
+
368
+ ## Next up
369
+
370
+ The controller and routes give us a working web interface, but setting up DataPorter in a host app still requires creating a migration, writing an initializer, mounting routes, and knowing the right directory for target classes. In part 11, we build **install and target generators** -- `rails generate data_porter:install` that handles the entire setup in one command, and `rails generate data_porter:target` that scaffolds a new import type with columns pre-filled from the command line.
371
+
372
+ ---
373
+
374
+ *This is part 10 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: Building the UI with Phlex & Tailwind](#) | [Next: Generators: Install & Target Scaffolding](#)*