wor-simple_crud 0.3.0 → 0.4.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/CHANGELOG.md +31 -2
  4. data/README.md +93 -26
  5. data/lib/simple_crud/action_lambdas.rb +81 -0
  6. data/lib/simple_crud/controller_helpers.rb +11 -2
  7. data/lib/simple_crud/rspec/config.rb +18 -5
  8. data/lib/simple_crud/rspec/helpers/authentication.rb +32 -0
  9. data/lib/simple_crud/rspec/helpers/controller_options.rb +24 -0
  10. data/lib/simple_crud/rspec/helpers/models.rb +70 -0
  11. data/lib/simple_crud/rspec/helpers/policies.rb +25 -0
  12. data/lib/simple_crud/rspec/helpers/requests.rb +45 -0
  13. data/lib/simple_crud/rspec/helpers/settings.rb +49 -0
  14. data/lib/simple_crud/rspec/helpers.rb +16 -138
  15. data/lib/simple_crud/rspec.rb +1 -0
  16. data/lib/simple_crud/simple_crud_controller.rb +33 -81
  17. data/lib/simple_crud/version.rb +1 -1
  18. data/lib/simple_crud.rb +1 -0
  19. data/lib/spec/shared_examples/authorization_adapter_authorize.rb +1 -1
  20. data/lib/spec/shared_examples/simple_crud_for_create.rb +16 -13
  21. data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +26 -8
  22. data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +3 -3
  23. data/lib/spec/shared_examples/simple_crud_for_destroy.rb +26 -1
  24. data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +11 -3
  25. data/lib/spec/shared_examples/simple_crud_for_destroy_with_finder.rb +18 -7
  26. data/lib/spec/shared_examples/simple_crud_for_edit.rb +79 -0
  27. data/lib/spec/shared_examples/simple_crud_for_index.rb +2 -2
  28. data/lib/spec/shared_examples/simple_crud_for_index_with_block.rb +12 -4
  29. data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +26 -14
  30. data/lib/spec/shared_examples/simple_crud_for_new.rb +2 -2
  31. data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +14 -3
  32. data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +11 -5
  33. data/lib/spec/shared_examples/simple_crud_for_show.rb +1 -1
  34. data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +14 -3
  35. data/lib/spec/shared_examples/simple_crud_for_show_with_finder.rb +17 -8
  36. data/lib/spec/shared_examples/simple_crud_for_update.rb +17 -10
  37. data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +17 -7
  38. data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +3 -2
  39. data/lib/spec/shared_examples/simple_crud_renders_template.rb +1 -1
  40. data/lib/spec/shared_examples/simple_crud_when_not_authorized.rb +1 -1
  41. data/lib/spec/shared_examples/simple_crud_without_authenticated_user.rb +1 -1
  42. data/lib/spec/shared_examples/unauthorized_when_not_logged_in.rb +1 -1
  43. data/spec/block_finder/dummy_models_controller_spec.rb +10 -0
  44. data/spec/block_json/dummy_models_controller_spec.rb +9 -0
  45. data/spec/block_redirect/dummy_models_controller_spec.rb +7 -0
  46. data/spec/dummy/app/controllers/block/dummy_models_controller.rb +1 -6
  47. data/spec/dummy/app/controllers/block_finder/dummy_models_controller.rb +25 -0
  48. data/spec/dummy/app/controllers/block_json/dummy_models_controller.rb +17 -0
  49. data/spec/dummy/app/controllers/block_new/dummy_models_controller.rb +1 -1
  50. data/spec/dummy/app/controllers/block_redirect/dummy_models_controller.rb +13 -0
  51. data/spec/dummy/app/controllers/block_show/dummy_models_controller.rb +1 -1
  52. data/spec/dummy/app/controllers/built/dummy_models_controller.rb +0 -5
  53. data/spec/dummy/app/controllers/dummy_models_controller.rb +0 -5
  54. data/spec/dummy/app/controllers/finder/dummy_models_controller.rb +1 -5
  55. data/spec/dummy/app/controllers/html_finder/dummy_models_controller.rb +17 -0
  56. data/spec/dummy/app/controllers/html_modes/base_controller.rb +0 -5
  57. data/spec/dummy/app/controllers/html_scoped/dummy_models_controller.rb +1 -1
  58. data/spec/dummy/app/controllers/invalid_status/dummy_models_controller.rb +1 -1
  59. data/spec/dummy/app/controllers/nested/dummy_models_controller.rb +0 -5
  60. data/spec/dummy/app/controllers/nested_route/dummy_models_controller.rb +0 -5
  61. data/spec/dummy/app/controllers/redirect_auth/dummy_models_controller.rb +0 -5
  62. data/spec/dummy/app/controllers/scoped/dummy_models_controller.rb +0 -5
  63. data/spec/dummy/app/controllers/scoped_params/dummy_models_controller.rb +0 -5
  64. data/spec/dummy/app/controllers/strict/dummy_models_controller.rb +0 -5
  65. data/spec/dummy/app/controllers/unpaginated_scoped/dummy_models_controller.rb +11 -0
  66. data/spec/dummy/app/controllers/without_pagination/dummy_models_controller.rb +0 -5
  67. data/spec/dummy/app/models/dummy_model_policy.rb +5 -1
  68. data/spec/dummy/app/views/html_finder/dummy_models/edit.html.erb +1 -0
  69. data/spec/dummy/app/views/html_finder/dummy_models/new.html.erb +1 -0
  70. data/spec/dummy/app/views/html_finder/dummy_models/show.html.erb +1 -0
  71. data/spec/dummy/config/routes.rb +21 -1
  72. data/spec/dummy_model_policy_spec.rb +18 -8
  73. data/spec/finder/dummy_models_controller_spec.rb +1 -0
  74. data/spec/html_finder/dummy_models_controller_spec.rb +11 -0
  75. data/spec/html_scoped/dummy_models_controller_spec.rb +1 -1
  76. data/spec/scoped_params/dummy_models_controller_spec.rb +4 -3
  77. data/spec/simple_crud/rspec_config_spec.rb +39 -0
  78. data/spec/simple_crud_controller_spec.rb +38 -0
  79. data/spec/unpaginated_scoped/dummy_models_controller_spec.rb +7 -0
  80. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 667672176b1c817bdcf1d9c5248e1b3701253d52f289a85bc2e4b3e632c47188
4
- data.tar.gz: f03748bda6004b670a8989fed13d82e0c6e0e0ec6bd9323a1c4e43e786da4dfe
3
+ metadata.gz: dbcb6881c359fa3b5c4ce518871a4f378d92e0698ab7d57dc489315f2f25b80b
4
+ data.tar.gz: 62cf8cbd6df845cd23a388e9b2a1f8ebfdae784ff91f4e57268688bce0c0bf87
5
5
  SHA512:
6
- metadata.gz: '04257318d37af96ef5df3d17d2561dfad551cf3eaca51dc4630d7e7d9a7d19cd05852a4b31e00902ab245610d0e35379e3314586f18646afe3ddb246a17a1756'
7
- data.tar.gz: 81b0fda087fc2723f8f7a6789f7f2542037b7054b36e9db8aed05eba03f5cdbcb0c38fdf45da85f60e9b3e67c58332118d73be257e5d16b108559eade35adba7
6
+ metadata.gz: 4406326eab40876599274e927df02c9c3470d3f753dcd5f0f150b67a7b3087e2a965ec0fd18ad1e007bada514954f9a665d573d18da98bf7c93aee3ffebc1637
7
+ data.tar.gz: 1deddc698fb6008531d59e5656675086a309dbc26177b403c023e7743463425626b8e977785f6ef2cbe122d4f251ad697944d1c71f665e498666feecedbae792
data/.gitignore CHANGED
@@ -147,6 +147,11 @@ config/initializers/secret_token.rb
147
147
  ## Environment normalization:
148
148
  /.bundle
149
149
  /vendor/bundle
150
+ # Appraisal gemfiles vendor their own bundles; never commit those.
151
+ /gemfiles/.bundle/
152
+ /gemfiles/vendor/
153
+ # Local tooling logs.
154
+ /.setting/
150
155
 
151
156
  # these should all be checked in to normalize the environment:
152
157
  # Gemfile.lock, .ruby-version, .ruby-gemset
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  ## Change log
2
2
 
3
+ ### V0.4.0
4
+
5
+ Breaking:
6
+ * Blocks given to `simple_crud_for` no longer imply `html: true`. HTML controllers with render blocks must pass `html: true` explicitly.
7
+
8
+ New options:
9
+ * `authenticate_headers:` controls whether shared examples set auth headers and run the unauthorized test, independent of `authenticate:`. Defaults to `authenticate:`. Set `authenticate: false, authenticate_headers: true` when a base-controller `before_action` handles auth so the lambda skips the call but tests still cover the auth path.
10
+ * `simple_crud_defaults(options)` sets option defaults at the controller level. Subclasses inherit. Per-action options override. Precedence: gem defaults < controller defaults < per-action options.
11
+
12
+ Fixes:
13
+ * Use `RSpec.shared_examples` instead of the top-level alias, which is unavailable with `config.disable_monkey_patching!`.
14
+ * Auto-require `SimpleCrud::Authorization::Adapter` on `require 'simple_crud'`.
15
+ * Remove `cattr_accessor :params, :permitted` (thread-unsafe, never read by the gem). Remove the `set_params` boilerplate from host controllers.
16
+ * Raise a clear error when `owner_association` names an attribute the model doesn't have, instead of a bare `NoMethodError` from FactoryBot.
17
+
18
+ ### V0.3.1
19
+
20
+ Shared examples configuration:
21
+ * Every setting can be overridden per controller or per example via `simple_crud:` RSpec metadata. Metadata takes precedence over `SimpleCrud::RSpec.configure` and the defaults, and all reads happen at example runtime, so per-resource tweaks need no around hooks, no global mutation and no restore logic.
22
+ * New `model_attributes` setting (default: `{ owner_association => current_user }`) decides how records are built for the examples, so multi-key (`{ user:, project: }`), non-user-owned (`{ project: project }`) or owner-less models work without editing the shared examples.
23
+ * Dedicated examples (`*_with_block`, `*_with_build`, `*_with_scope`) no longer assume the gem's dummy app: request params derive from `params_for`/`owner_params`, authentication wraps conditionally on the action's `authenticate:` option, and render-block actions are asserted on status and persistence effects only (a block's response body is app-defined by definition).
24
+ * The finder/build dedicated families (`show/update/destroy with finder`, `not found with finder`, `new with build`) adapt to each action's declared options too: render-block actions are asserted success-only, `html: true` actions assert the rendered template or redirect, and plain JSON keeps the status/body assertions. Server-rendered apps no longer need to skip these families.
25
+ * `index with scope` builds its records through new `scoped_attributes` (default: `model_attributes`) and `other_scoped_attributes` (default: `model_attributes.merge(owner_association => other_user)`) settings instead of hardcoding `owner_association => user`, so factories without that attribute keep working and multi-key models express their scope via metadata. The assertion follows the controller: `assigns(:records)` ids for `html: true`, the wor-paginate envelope for paginated JSON, and the bare serialized array otherwise.
26
+ * Actions declared with a render block expose `block: true` in the controller metadata, and the shared examples treat them accordingly: failure/invalid paths assert persistence effects only (the block decides how the response is rendered), while default flows keep their pinned statuses/templates.
27
+ * The base destroy example covers the failure path (record always kept; non-block flows respond 422 as JSON or re-render `show` as HTML).
28
+ * New optional `created_record_check` setting: a lambda receiving the persisted record in the create/update success paths, to assert ownership/scope that `count == 1` alone can't catch.
29
+
3
30
  ### V0.3.0
4
31
 
5
32
  Server-rendered (HTML) support:
@@ -7,8 +34,9 @@ Server-rendered (HTML) support:
7
34
  * New `:new` action: builds a record, authorizes it, and renders `new.html.erb` (or returns JSON in API mode).
8
35
 
9
36
  Custom lookups, scoping and building:
10
- * `finder:` on `:show`/`:update`/`:destroy`: a `Proc`/lambda invoked with the controller's params, or a `Symbol` naming a class method on the model. Defaults to `klass.find(params[:id])`.
11
- * `build:` on `:new`/`:create` for owner-scoped or nested builds (`current_user.classrooms.build`); runs with the controller as `self`, and `:create` assigns the permitted params to the built record before saving.
37
+ * `finder:` on `:show`/`:update`/`:destroy`/`:edit`: a `Proc`/lambda invoked with the controller's params, or a `Symbol` naming a class method on the model. Defaults to `klass.find(params[:id])`.
38
+ * New `simple_crud_for :edit`: the find-instead-of-build twin of `:new`. It looks the record up (honoring `finder:`), authorizes it and renders `edit.html.erb` with `@record` in HTML mode, returning the record as JSON otherwise. A matching `simple crud for edit` shared example covers the authorized/denied, not-found and html/json/block paths.
39
+ * `build:` on `:new`/`:create` for owner-scoped or nested builds (`current_user.projects.build`); runs with the controller as `self`, and `:create` assigns the permitted params to the built record before saving.
12
40
  * With `authorize: true`, `:index` paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, with a per-action `scope: ->(user[, params]) { ... }` override.
13
41
  * `redirect:` on HTML-mode `:create`/`:update`/`:destroy`: a `Proc` called with the record, or a literal path, overriding the default success redirect.
14
42
 
@@ -17,6 +45,7 @@ Validation errors:
17
45
 
18
46
  Authorization changes:
19
47
  * Breaking: `authorize: true` now always enforces policy checks, including for `authenticate: false` actions, which reach policies with a `nil` user. Policies must tolerate a `nil` user.
48
+ * Breaking: `:index` authorizes the model class instead of an unsaved instance, matching the Pundit/CanCanCan collection conventions. `index?` policies can no longer inspect record state (there is none), so write them against the user.
20
49
  * `SimpleCrud::Config.user_method` (default `:current_user`) selects the controller method providing the user to policies and scope lambdas, so apps with other conventions (`current_admin`, ...) need no shims.
21
50
 
22
51
  Adapter interfaces:
data/README.md CHANGED
@@ -18,6 +18,7 @@ SimpleCrud
18
18
  - [Serializer](#serializer)
19
19
  - [HTML](#html)
20
20
  - [Finder](#finder)
21
+ - [Controller-level defaults](#controller-level-defaults)
21
22
  - [Shared examples](#shared-examples)
22
23
  - [Contributing](#contributing)
23
24
  - [Releases](#releases)
@@ -68,12 +69,6 @@ Before SimpleCrud can be used, some boilerplate is needed. Add the following to
68
69
  include Pundit::Authorization
69
70
  include Wor::Paginate
70
71
  extend SimpleCrudController
71
-
72
- before_action :set_params
73
-
74
- def set_params
75
- SimpleCrudController.params = params
76
- end
77
72
  ```
78
73
 
79
74
  (Skip `include Pundit::Authorization` / `include Wor::Paginate` if you're using a different adapter, or `authorize: false` / `paginate: false` everywhere.)
@@ -95,6 +90,7 @@ simple_crud_for :index
95
90
  simple_crud_for :create
96
91
  simple_crud_for :destroy
97
92
  simple_crud_for :new
93
+ simple_crud_for :edit
98
94
  ```
99
95
 
100
96
  Each method supports different options, as in:
@@ -104,15 +100,32 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
104
100
 
105
101
  - Paginate: whether it should paginate or not. `true` paginates via the configured pagination adapter (wor-paginate by default), `false` doesn't paginate
106
102
  - Authorize: whether it should check authorization via the configured authorization adapter (Pundit by default)
107
- - Authenticate: whether it should use Devise to check for a current_user
103
+ - Authenticate: whether the generated action calls `authenticate_user!`. `true` (default) or `false`
104
+ - Authenticate_headers: whether shared examples set auth headers and run the unauthorized test. Defaults to `authenticate:`. Set independently when a base-controller `before_action` handles auth and the lambda should skip `authenticate_user!`
108
105
  - Serializer: specify a particular serializer you should use
109
- - Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
106
+ - Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:edit`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
110
107
  - Scope: only valid for `:index`. A `Proc`/`lambda` taking `current_user` (plus the controller's `params` if it takes a second argument) that returns the relation to list, overriding the default `policy_scope`. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
111
- - Finder: only valid for `:show`, `:update` and `:destroy`. A `Proc`/`lambda` (invoked with the controller's params) or a `Symbol` naming a class method on the model, used to look up the record instead of `klass.find(params[:id])`.
112
- - Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.classrooms.build`. `:create` then assigns the permitted params to the built record before saving
108
+ - Finder: only valid for `:show`, `:update`, `:destroy` and `:edit`. A `Proc`/`lambda` (invoked with the controller's params) or a `Symbol` naming a class method on the model, used to look up the record instead of `klass.find(params[:id])`.
109
+ - Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.projects.build`. `:create` then assigns the permitted params to the built record before saving
113
110
  - Raise_on_invalid: only valid for `:create` and `:update`. Keeps the strict `create!`/`update!` semantics (raising on invalid input) instead of returning `422` with the validation errors
114
111
  - Redirect: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `Proc`/`lambda` (called with the record) or a literal path used as the success redirect target. Defaults to the record (`:create`/`:update`) or the model's collection path (`:destroy`)
115
112
 
113
+ #### Controller-level defaults
114
+
115
+ `simple_crud_defaults` sets option defaults for every `simple_crud_for` in the controller. Subclasses inherit; per-action options override.
116
+
117
+ ```ruby
118
+ class Api::BaseController < ActionController::API
119
+ extend SimpleCrudController
120
+ simple_crud_defaults authorize: false, authenticate: false, authenticate_headers: true
121
+ end
122
+
123
+ class Api::PostsController < Api::BaseController
124
+ simple_crud_for :show # inherits defaults
125
+ simple_crud_for :create, authorize: true # overrides for this action only
126
+ end
127
+ ```
128
+
116
129
  You'll need a few things so they work correctly:
117
130
 
118
131
  ### Options
@@ -246,7 +259,13 @@ end
246
259
  ```
247
260
 
248
261
  #### Authenticate
249
- SimpleCrud will assume a current_user method. Future versions will support a custom model. Defining a current_user method in ApplicationController should work if you're using a different model, as of now.
262
+ `authenticate: true` (default) calls `authenticate_user!` inside the generated action. `authenticate: false` skips it.
263
+
264
+ If your base controller already runs `before_action :authenticate_user!`, set `authenticate: false` to avoid a double call and `authenticate_headers: true` so shared examples still cover the auth path:
265
+
266
+ ```ruby
267
+ simple_crud_defaults authenticate: false, authenticate_headers: true
268
+ ```
250
269
 
251
270
  #### Serializer
252
271
  The name of the serializer, by default, is the name of the model followed by Serializer, as is the standard for [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers). It's possible to just pass a custom serializer class though. As for the serializer itself, it's a standard serializer, with the gotcha that you need to include `:id` for the SimpleCrud examples to work.
@@ -263,6 +282,7 @@ For server-rendered apps, `html: true` renders the action's ERB template instead
263
282
  - `:index` renders `index.html.erb` with the paginated records exposed as `@records` (pagination still applies, or use `paginate: false`).
264
283
  - `:show` renders `show.html.erb` with the record exposed as `@record` (custom `finder:` still applies).
265
284
  - `:new` builds a new record, authorizes it, and renders `new.html.erb` with it exposed as `@record`.
285
+ - `:edit` looks the record up (honoring `finder:`), authorizes it, and renders `edit.html.erb` with it exposed as `@record`.
266
286
  - `:create` saves and redirects to the created record on success, or re-renders `new.html.erb` (with `@record` and its errors) on failure.
267
287
  - `:update` saves and redirects to the record on success, or re-renders `edit.html.erb` on failure.
268
288
  - `:destroy` destroys and redirects to the collection (`redirect_to Model`) on success, or re-renders `show.html.erb` if a callback aborts the destroy.
@@ -271,33 +291,47 @@ For server-rendered apps, `html: true` renders the action's ERB template instead
271
291
  simple_crud_for :index, html: true
272
292
  simple_crud_for :show, html: true
273
293
  simple_crud_for :new, html: true
294
+ simple_crud_for :edit, html: true
274
295
  simple_crud_for :create, html: true
275
296
  simple_crud_for :update, html: true
276
297
  simple_crud_for :destroy, html: true
277
298
  ```
278
299
 
279
- Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`. Passing a block also implies `html: true` for the shared examples (so a server-rendered block is asserted as HTML); if your block renders JSON instead, pass `html: false` explicitly:
300
+ Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`. Blocks do not change the `html:` default; pass it explicitly so the shared examples know which request format to use:
280
301
 
281
302
  ```ruby
282
- simple_crud_for :index do |records|
303
+ # Server-rendered block: pass html: true so shared examples send HTML requests
304
+ simple_crud_for :index, html: true do |records|
283
305
  render :index, locals: { models: records }
284
306
  end
285
307
 
286
- simple_crud_for :create do |record, saved|
308
+ simple_crud_for :create, html: true do |record, saved|
287
309
  saved ? redirect_to(record) : render(:new, locals: { model: record })
288
310
  end
311
+
312
+ # API block: html: false is the default
313
+ simple_crud_for :show, html: false do |record|
314
+ render json: record.as_json(only: %i[id name])
315
+ end
289
316
  ```
290
317
 
291
318
  #### Build
292
- `simple_crud_for :new` and `simple_crud_for :create` build the record with `klass.new`, which can't express owner-scoped or nested records (`current_user.classrooms.build`, `@classroom.assignments.build`). Pass a `build:` lambda; it runs with the controller as `self`, so `current_user`, `params` and any instance variables set by a `before_action` are available:
319
+ `simple_crud_for :new` and `simple_crud_for :create` build the record with `klass.new`, which can't express owner-scoped or nested records (`current_user.projects.build`, `@project.tasks.build`). Pass a `build:` lambda; it runs with the controller as `self`, so `current_user`, `params` and any instance variables set by a `before_action` are available:
293
320
 
294
321
  ```ruby
295
- simple_crud_for :new, build: -> { current_user.classrooms.build }
296
- simple_crud_for :create, build: -> { current_user.classrooms.build }
322
+ simple_crud_for :new, build: -> { current_user.projects.build }
323
+ simple_crud_for :create, build: -> { @project.tasks.build }
297
324
  ```
298
325
 
299
326
  `:create` assigns the permitted params to the built record before saving, so the owner/parent association survives. `:update` keeps finding the record via the `finder:`.
300
327
 
328
+ #### Edit
329
+ `simple_crud_for :edit` is the find-instead-of-build twin of `:new`, the last hand-written CRUD action in server-rendered apps. It looks the record up (honoring `finder:` exactly like `:show`), authorizes it, and renders `edit.html.erb` with the record exposed as `@record`; in JSON mode it returns the record:
330
+
331
+ ```ruby
332
+ simple_crud_for :edit, finder: ->(params) { Project.find_by!(slug: params[:slug]) }
333
+ ```
334
+
301
335
  #### Finder
302
336
  By default records are looked up by primary key via `klass.find(params[:id])`. The `finder:` option on `:show`, `:update` and `:destroy` replaces that with any lookup you want: pretty URLs (`resources :posts, param: :slug`), tokens, composite keys, or scoping by a parent resource. Pass a `Proc`/`lambda` that maps the controller's `params` to a record, or a `Symbol` naming a class method on the model that takes the params:
303
337
 
@@ -310,7 +344,7 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
310
344
  When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
311
345
 
312
346
  ### Shared examples
313
- While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use `simple_crud_for`, as a set of basic tests. To include them, just add `require 'simple_crud/rspec'` to your `rails_helper.rb` file and add the lines you need to your `*_spec.rb` files:
347
+ While optional, using the included shared examples saves you from writing the standard test cases for the methods. You can even use them if you didn't use `simple_crud_for`, as a set of basic tests. To include them, add `require 'simple_crud/rspec'` to your `rails_helper.rb` **after** `require "rspec/rails"`, then add the lines you need to your `*_spec.rb` files:
314
348
  ```ruby
315
349
  require 'rails_helper'
316
350
 
@@ -323,10 +357,11 @@ describe V1::Backoffice::AuthorsController do
323
357
  end
324
358
  ```
325
359
 
326
- The `create` and `update` examples cover the `422` response with validation errors (skipped when `raise_on_invalid: true`), and all base examples adapt to `html: true` controllers (asserting the rendered template/redirect instead of JSON). Controllers using the extra options can include their dedicated examples too:
360
+ The `create` and `update` examples cover the `422` response with validation errors (skipped when `raise_on_invalid: true`, or whenever the action uses a render block, since the block decides how the response is rendered), and all base examples adapt to `html: true` controllers (asserting the rendered template/redirect instead of JSON). Controllers using the extra options can include their dedicated examples too:
327
361
 
328
362
  ```ruby
329
363
  include_examples 'simple crud for new' # the :new action
364
+ include_examples 'simple crud for edit' # the :edit action
330
365
  include_examples 'simple crud for index with block' # render block
331
366
  include_examples 'simple crud for index with scope' # scope: ->(user) { ... }
332
367
  include_examples 'simple crud for show with block' # render block on :show
@@ -359,8 +394,11 @@ SimpleCrud::RSpec.configure do |config|
359
394
  config.create_records = ->(klass, count, attributes) { count.times.map { klass.create!(attributes) } }
360
395
  config.params_for = ->(klass) { klass.new.attributes.slice('title') }
361
396
 
362
- # The owner association and the validation this app's models enforce.
363
- config.owner_association = :instructor
397
+ # The owner association used when building records for the examples
398
+ # (model_attributes defaults to `{ owner_association => current_user }`;
399
+ # override model_attributes directly for multi-key or non-user-owned models).
400
+ config.owner_association = :owner
401
+ config.model_attributes = -> { { owner: current_user } }
364
402
  config.required_attribute = :title
365
403
  config.required_error = "Title can't be blank"
366
404
 
@@ -371,24 +409,53 @@ SimpleCrud::RSpec.configure do |config|
371
409
  config.assert_html_template = false
372
410
 
373
411
  # Server-rendered apps usually use nested strong params
374
- # (params.require(:classroom).permit(:name)); wrap request bodies under the
412
+ # (params.require(:task).permit(:name)); wrap request bodies under the
375
413
  # model's params key instead of posting flat params.
376
- config.params_key = :classroom
414
+ config.params_key = :task
377
415
 
378
- # Nested resources (/classrooms/:classroom_slug/assignments): extra params
416
+ # Nested resources (/projects/:project_slug/tasks): extra params
379
417
  # (e.g. the parent slug) added to every request.
380
- config.route_params = -> { { classroom_slug: model.classroom_slug } }
418
+ config.route_params = -> { { project_slug: project.slug } }
381
419
 
382
420
  # Re-render the form with a 422 on validation failure (instead of 200).
383
421
  config.invalid_status = :unprocessable_entity
384
422
 
423
+ # How the 'simple crud for index with scope' example builds the records
424
+ # that match the controller's scope and the ones that must be excluded.
425
+ # Defaults derive from model_attributes (mine) plus an owner_association
426
+ # => other_user variant (theirs); override both when your scope keys on
427
+ # something other than the owner association.
428
+ config.scoped_attributes = -> { { project: project } }
429
+ config.other_scoped_attributes = -> { { project: other_project } }
430
+
385
431
  # Pundit policy and serializer class lookup.
386
432
  config.policy_class = ->(klass) { "#{klass}Policy".constantize }
387
433
  config.serializer_class = ->(model) { "#{model.class}Serializer".constantize }
434
+
435
+ # Optional extra assertions on the persisted record in the create/update
436
+ # success paths (catches regressions that save into the wrong parent).
437
+ config.created_record_check = ->(record) { expect(record.project).to eq(project) }
438
+ end
439
+ ```
440
+
441
+ Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `model_attributes`, `scoped_attributes`, `other_scoped_attributes`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc.; `created_record_check` receives the persisted record. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep `assert_html_template` on (the default), add `gem 'rails-controller-testing'` for the `render_template` matcher.
442
+
443
+ #### Per-controller overrides via metadata
444
+
445
+ Any setting can also be overridden per controller (or per example) with `simple_crud:` metadata instead of globally, useful when the app matches the defaults except for one resource. Metadata wins over global config, and every read happens at example runtime, so there are no around hooks or state to restore:
446
+
447
+ ```ruby
448
+ RSpec.describe TasksController, type: :controller, simple_crud: {
449
+ model_attributes: -> { { project: project } },
450
+ route_params: -> { { project_slug: project.slug } }
451
+ } do
452
+ let(:project) { create(:project, owner: current_user) }
453
+
454
+ include_examples 'simple crud for show'
388
455
  end
389
456
  ```
390
457
 
391
- Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep `assert_html_template` on (the default), add `gem 'rails-controller-testing'` for the `render_template` matcher.
458
+ Callable settings resolve in the example context, so their lambdas can close over the spec's `let`s; that's how records land in the same project the route params point at.
392
459
 
393
460
  ## Contributing
394
461
 
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Builds the lambda installed as each action declared with simple_crud_for.
5
+ module ActionLambdas
6
+ def crud_lambda_for_show(klass, parameters = {}, &block)
7
+ lambda do
8
+ authenticate_user! if parameters[:authenticate]
9
+ requested = SimpleCrudController.find_record(klass, self, parameters)
10
+
11
+ options = {}.merge(serializer: parameters[:serializer]).compact
12
+ SimpleCrudController.maybe_authorize(self, requested, parameters)
13
+ SimpleCrudController.render_show(self, requested, options, parameters, &block)
14
+ end
15
+ end
16
+
17
+ def crud_lambda_for_new(klass, parameters = {}, &block)
18
+ lambda do
19
+ authenticate_user! if parameters[:authenticate]
20
+ record = SimpleCrudController.build_record(self, klass, parameters)
21
+ SimpleCrudController.maybe_authorize(self, record, parameters)
22
+ SimpleCrudController.render_new(self, record, parameters, &block)
23
+ end
24
+ end
25
+
26
+ # The find-instead-of-build twin of :new.
27
+ def crud_lambda_for_edit(klass, parameters = {}, &block)
28
+ lambda do
29
+ authenticate_user! if parameters[:authenticate]
30
+ requested = SimpleCrudController.find_record(klass, self, parameters)
31
+ SimpleCrudController.maybe_authorize(self, requested, parameters)
32
+ SimpleCrudController.render_edit(self, requested, parameters, &block)
33
+ end
34
+ end
35
+
36
+ def crud_lambda_for_index(klass, parameters = {}, &block)
37
+ lambda do
38
+ authenticate_user! if parameters[:authenticate]
39
+ SimpleCrudController.maybe_authorize(self, klass, parameters)
40
+ options = {}.merge(each_serializer: parameters[:serializer]).compact
41
+ SimpleCrudController.render_index(self, klass, options, parameters, &block)
42
+ end
43
+ end
44
+
45
+ def crud_lambda_for_create(klass, parameters = {}, &block)
46
+ lambda do
47
+ authenticate_user! if parameters[:authenticate]
48
+ permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
49
+ record = SimpleCrudController.build_record(self, klass, parameters)
50
+ record.assign_attributes(permitted_params)
51
+ SimpleCrudController.maybe_authorize(self, record, parameters)
52
+ persist = ->(bang:) { bang ? record.save! : record.save }
53
+ options = { status: :created, failure_template: :new }
54
+ SimpleCrudController.save_and_render(self, record, parameters, options, persist, &block)
55
+ end
56
+ end
57
+
58
+ def crud_lambda_for_update(klass, parameters = {}, &block)
59
+ lambda do
60
+ authenticate_user! if parameters[:authenticate]
61
+ requested = SimpleCrudController.find_record(klass, self, parameters)
62
+ SimpleCrudController.maybe_authorize(self, requested, parameters)
63
+ permitted_params = send("#{self.class.simple_crud_controller_model.to_s.underscore}_params")
64
+ persist = ->(bang:) { bang ? requested.update!(permitted_params) : requested.update(permitted_params) }
65
+ options = { status: :ok, failure_template: :edit }
66
+ SimpleCrudController.save_and_render(self, requested, parameters, options, persist, &block)
67
+ end
68
+ end
69
+
70
+ def crud_lambda_for_destroy(klass, parameters = {}, &block)
71
+ lambda do
72
+ authenticate_user! if parameters[:authenticate]
73
+ requested = SimpleCrudController.find_record(klass, self, parameters)
74
+ SimpleCrudController.maybe_authorize(self, requested, parameters)
75
+ options = { status: :ok, failure_template: :show, redirect: parameters[:redirect] || klass }
76
+ persist = ->(bang:) { bang ? requested.destroy! : requested.destroy }
77
+ SimpleCrudController.persist_and_render(self, requested, parameters, options, persist, &block)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -33,11 +33,20 @@ module SimpleCrud
33
33
  end
34
34
 
35
35
  def render_new(controller, record, parameters, &block)
36
+ render_form(controller, record, :new, parameters, &block)
37
+ end
38
+
39
+ def render_edit(controller, record, parameters, &block)
40
+ render_form(controller, record, :edit, parameters, &block)
41
+ end
42
+
43
+ def render_form(controller, record, template, parameters, &block)
36
44
  if parameters[:html] || block
37
45
  controller.instance_variable_set(:@record, record)
38
- block ? controller.instance_exec(record, &block) : controller.render(:new)
46
+ block ? controller.instance_exec(record, &block) : controller.render(template)
39
47
  else
40
- controller.render json: record
48
+ options = {}.merge(serializer: parameters[:serializer]).compact
49
+ controller.render({ json: record }.merge(options))
41
50
  end
42
51
  end
43
52
 
@@ -14,10 +14,10 @@ module SimpleCrud
14
14
  # Serializers), so apps on a different stack can override them via
15
15
  # SimpleCrud::RSpec.configure instead of editing the examples.
16
16
  #
17
- # Callable settings (authenticate, current_user, create_record,
18
- # create_records, attributes_for, policy_class, serializer_class) run in
19
- # the example-group context, so they can call helpers such as `create`,
20
- # `request` and `current_user`.
17
+ # Callable settings (authenticate, current_user, other_user,
18
+ # create_record, create_records, params_for, model_attributes,
19
+ # policy_class, serializer_class) run in the example-group context, so
20
+ # they can call helpers such as `create`, `request` and `current_user`.
21
21
  class Config
22
22
  DEFAULTS = {
23
23
  authenticate: lambda {
@@ -30,6 +30,18 @@ module SimpleCrud
30
30
  create_records: ->(klass, count, attributes) { create_list(klass, count, **attributes) },
31
31
  params_for: ->(klass) { attributes_for(klass) },
32
32
  owner_association: :user,
33
+ model_attributes: -> { owner_association ? { owner_association => current_user } : {} },
34
+ scoped_attributes: -> { model_attributes },
35
+ other_scoped_attributes: lambda {
36
+ attributes = model_attributes
37
+ if owner_association && !attributes.is_a?(Hash)
38
+ raise ArgumentError,
39
+ 'model_attributes must resolve to a Hash when owner_association is set; ' \
40
+ 'override other_scoped_attributes instead'
41
+ end
42
+
43
+ owner_association ? attributes.merge(owner_association => other_user) : {}
44
+ },
33
45
  required_attribute: :name,
34
46
  required_error: "Name can't be blank",
35
47
  finder_key: :slug,
@@ -39,7 +51,8 @@ module SimpleCrud
39
51
  unauthenticated_status: :unauthorized,
40
52
  assert_html_template: true,
41
53
  policy_class: ->(klass) { "#{klass}Policy".constantize },
42
- serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize }
54
+ serializer_class: ->(model) { "#{model.class}_serializer".classify.constantize },
55
+ created_record_check: nil
43
56
  }.freeze
44
57
 
45
58
  class << self
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ module RSpec
5
+ module Helpers
6
+ # Current/other users, signing requests in and the owner association
7
+ # attributed to the current user.
8
+ module Authentication
9
+ def current_user
10
+ @current_user ||= instance_exec(&setting(:current_user))
11
+ end
12
+
13
+ def other_user
14
+ @other_user ||= instance_exec(&setting(:other_user))
15
+ end
16
+
17
+ def authenticate_request
18
+ instance_exec(&setting(:authenticate))
19
+ end
20
+
21
+ def owner_foreign_key
22
+ :"#{owner_association}_id"
23
+ end
24
+
25
+ # The owner association attributed to current_user, if the model has one.
26
+ def owner_params
27
+ owner_association ? { owner_foreign_key => current_user.id } : {}
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ module RSpec
5
+ module Helpers
6
+ # Definition-time introspection of the options each action was declared
7
+ # with via simple_crud_for (read from the controller's metadata).
8
+ module ControllerOptions
9
+ def get_option(method, option)
10
+ described_class.instance_variable_get(:@simple_crud_metadata)[method][option]
11
+ end
12
+
13
+ %i[paginate authorize authenticate serializer html finder scope build raise_on_invalid block
14
+ authenticate_headers].each do |option|
15
+ define_method("check_#{option}") { |method| get_option(method, option) }
16
+ end
17
+
18
+ def check_authenticate(method)
19
+ check_authenticate_headers(method)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ module RSpec
5
+ module Helpers
6
+ # Building the record under test and the params used to persist it.
7
+ module Models
8
+ def model_class
9
+ described_class.to_s.split('::')
10
+ .last.sub('Controller', '').singularize.underscore
11
+ end
12
+
13
+ def model_class_object
14
+ model_class.classify.constantize
15
+ end
16
+
17
+ def model
18
+ @model ||= begin
19
+ validate_owner_association!
20
+ create_record(model_class, model_attributes)
21
+ end
22
+ end
23
+
24
+ def validate_owner_association!
25
+ assoc = owner_association
26
+ return unless assoc
27
+
28
+ klass = model_class_object
29
+ return if klass.method_defined?("#{assoc}=") || klass.column_names.include?(assoc.to_s)
30
+
31
+ raise "simple_crud: owner_association is :#{assoc} but #{klass} has no #{assoc}= setter. " \
32
+ 'Set `simple_crud: { owner_association: nil }` in your describe block or ' \
33
+ 'override model_attributes directly.'
34
+ end
35
+
36
+ def model_attributes
37
+ resolve(setting(:model_attributes))
38
+ end
39
+
40
+ def create_record(klass, attributes)
41
+ instance_exec(klass, attributes, &setting(:create_record))
42
+ end
43
+
44
+ def create_records(klass, count, attributes)
45
+ instance_exec(klass, count, attributes, &setting(:create_records))
46
+ end
47
+
48
+ def params_for(klass)
49
+ instance_exec(klass, &setting(:params_for))
50
+ end
51
+
52
+ def model_params
53
+ @model_params ||= params_for(model_class)
54
+ end
55
+
56
+ def model_serializer
57
+ defined?(serializer) ? serializer : instance_exec(model, &setting(:serializer_class))
58
+ end
59
+
60
+ def rendered_record
61
+ controller.instance_variable_get(:@record)
62
+ end
63
+
64
+ def rendered_records
65
+ controller.instance_variable_get(:@records)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ module RSpec
5
+ module Helpers
6
+ # Locating the policy object for the model under test and stubbing it
7
+ # to allow or deny.
8
+ module Policies
9
+ def policy_class_object
10
+ instance_exec(model_class_object, &setting(:policy_class))
11
+ end
12
+
13
+ def make_policies_fail(method)
14
+ allow(policy_class_object).to receive(:new)
15
+ .and_return(instance_double(policy_class_object, "#{method}?" => false))
16
+ end
17
+
18
+ def make_policies_succeed(method)
19
+ allow(policy_class_object).to receive(:new)
20
+ .and_return(instance_double(policy_class_object, "#{method}?" => true))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end