dexkit 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7be5c6e58f2741692419cf25f9a8e76df8003036dd17139073561fe5141e3bdc
4
+ data.tar.gz: 28f77abedf2552c3a2f319abd175f61855f956517295e1ea435a3dbf88327735
5
+ SHA512:
6
+ metadata.gz: 800756f2f2f22dc4e487794fc129ccafc57ee815b3e26aea67c1f8eef9195654106cd7ab7b91525c4e5325da1696e94069ffc78e2d1302dc1f7a5d6f6eff8e82
7
+ data.tar.gz: 7bf8212783439db36adb9ae04854f981d2e2e6d9db8ca7bf9cd0768faa566aec98addc56f12640b7d0c284e3a7f4ca9d8afa05ba1f90568241970214bd8307d5
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-01
4
+
5
+ - Initial public release
6
+ - **Operation** base class with typed properties (via `literal` gem), structured errors, and Ok/Err result pattern
7
+ - Contract DSL: `success` and `error` declarations with runtime validation
8
+ - Safe execution: `.safe.call` returns Ok/Err instead of raising
9
+ - Rescue mapping: `rescue_from` to convert exceptions into typed errors
10
+ - Callbacks: `before`, `after`, `around` lifecycle hooks
11
+ - Async execution via ActiveJob (`async` DSL)
12
+ - Transaction support for ActiveRecord and Mongoid
13
+ - Advisory locking via `with_advisory_lock` gem
14
+ - Operation recording (persistence of execution results to DB)
15
+ - `Dex::RefType` — model reference type with automatic `find` coercion
16
+ - **Test helpers**: `call_operation`, `call_operation!`, result/contract/param assertions, async and transaction assertions, batch assertions
17
+ - **Stubbing & spying**: `stub_operation`, `spy_on_operation`
18
+ - **TestLog**: global activity log for test introspection
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Jacek Galanciak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # Dexkit
2
+
3
+ Rails patterns toolbelt. Equip to gain +4 DEX.
4
+
5
+ **[Documentation](https://dex.razorjack.net)**
6
+
7
+ ## Operations
8
+
9
+ Service objects with typed properties, transactions, error handling, and more.
10
+
11
+ ```ruby
12
+ class CreateUser < Dex::Operation
13
+ prop :name, String
14
+ prop :email, String
15
+
16
+ success _Ref(User)
17
+ error :email_taken
18
+
19
+ def perform
20
+ error!(:email_taken) if User.exists?(email: email)
21
+ User.create!(name: name, email: email)
22
+ end
23
+ end
24
+
25
+ user = CreateUser.call(name: "Alice", email: "alice@example.com")
26
+ user.name # => "Alice"
27
+ ```
28
+
29
+ ### What you get out of the box
30
+
31
+ **Typed properties** – powered by [literal](https://github.com/joeldrapper/literal). Plain classes, ranges, unions, arrays, nilable, and model references with auto-find:
32
+
33
+ ```ruby
34
+ prop :amount, _Integer(1..)
35
+ prop :currency, _Union("USD", "EUR", "GBP")
36
+ prop :user, _Ref(User) # accepts User instance or ID
37
+ prop? :note, String # optional (nil by default)
38
+ ```
39
+
40
+ **Structured errors** with `error!`, `assert!`, and `rescue_from`:
41
+
42
+ ```ruby
43
+ user = assert!(:not_found) { User.find_by(id: user_id) }
44
+
45
+ rescue_from Stripe::CardError, as: :card_declined
46
+ ```
47
+
48
+ **Ok / Err** – pattern match on operation outcomes with `.safe.call`:
49
+
50
+ ```ruby
51
+ include Dex::Match
52
+
53
+ case CreateUser.new(email: email).safe.call
54
+ in Ok(name:)
55
+ puts "Welcome, #{name}!"
56
+ in Err(code: :email_taken)
57
+ puts "Already registered"
58
+ end
59
+ ```
60
+
61
+ **Async execution** via ActiveJob:
62
+
63
+ ```ruby
64
+ SendWelcomeEmail.new(user_id: 123).async(queue: "mailers").call
65
+ ```
66
+
67
+ **Transactions** on by default, **advisory locking**, **recording** to database, **callbacks**, and a customizable **pipeline** – all composable, all optional.
68
+
69
+ ### Testing
70
+
71
+ First-class test helpers for Minitest:
72
+
73
+ ```ruby
74
+ class CreateUserTest < Minitest::Test
75
+ testing CreateUser
76
+
77
+ def test_creates_user
78
+ assert_operation(name: "Alice", email: "alice@example.com")
79
+ end
80
+
81
+ def test_rejects_duplicate_email
82
+ assert_operation_error(:email_taken, name: "Alice", email: "taken@example.com")
83
+ end
84
+ end
85
+ ```
86
+
87
+ ## Installation
88
+
89
+ ```ruby
90
+ gem "dexkit"
91
+ ```
92
+
93
+ ## Documentation
94
+
95
+ Full documentation at **[dex.razorjack.net](https://dex.razorjack.net)**.
96
+
97
+ ## AI Coding Assistant Setup
98
+
99
+ Dexkit ships LLM-optimized guides. Copy them into your project so AI agents automatically know the API:
100
+
101
+ ```bash
102
+ cp $(bundle show dexkit)/guides/llm/OPERATION.md app/operations/CLAUDE.md
103
+ cp $(bundle show dexkit)/guides/llm/TESTING.md test/CLAUDE.md
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,553 @@
1
+ # Dex::Operation — LLM Reference
2
+
3
+ Copy this to your app's operations directory (e.g., `app/operations/AGENTS.md`) so coding agents know the full API when implementing and testing operations.
4
+
5
+ ---
6
+
7
+ ## Reference Operation
8
+
9
+ All examples below build on this operation unless noted otherwise:
10
+
11
+ ```ruby
12
+ class CreateUser < Dex::Operation
13
+ prop :email, String
14
+ prop :name, String
15
+ prop? :role, _Union("admin", "member"), default: "member"
16
+
17
+ success _Ref(User)
18
+ error :email_taken, :invalid_email
19
+
20
+ def perform
21
+ error!(:invalid_email) unless email.include?("@")
22
+ error!(:email_taken) if User.exists?(email: email)
23
+ User.create!(email: email, name: name, role: role)
24
+ end
25
+ end
26
+ ```
27
+
28
+ **Calling:**
29
+
30
+ ```ruby
31
+ CreateUser.call(email: "a@b.com", name: "Alice") # shorthand for new(...).call
32
+ CreateUser.new(email: "a@b.com", name: "Alice").safe.call # Ok/Err wrapper
33
+ CreateUser.new(email: "a@b.com", name: "Alice").async.call # background job
34
+ ```
35
+
36
+ Use `new(...)` form when chaining modifiers (`.safe`, `.async`).
37
+
38
+ ---
39
+
40
+ ## Properties
41
+
42
+ Define typed inputs with `prop` (required) and `prop?` (optional — nilable, defaults to `nil`). Access directly as reader methods (`name`, `user`). Invalid values raise `Literal::TypeError`.
43
+
44
+ Reserved names: `call`, `perform`, `async`, `safe`, `initialize`.
45
+
46
+ ### Literal Types Cheatsheet
47
+
48
+ Types use `===` for validation. All constructors available in the operation class body:
49
+
50
+ | Constructor | Description | Example |
51
+ |-------------|-------------|---------|
52
+ | Plain class | Matches with `===` | `String`, `Integer`, `Float`, `Hash`, `Array` |
53
+ | `_Integer(range)` | Constrained integer | `_Integer(1..)`, `_Integer(0..100)` |
54
+ | `_String(constraints)` | Constrained string | `_String(length: 1..500)` |
55
+ | `_Array(type)` | Typed array | `_Array(Integer)`, `_Array(String)` |
56
+ | `_Union(*values)` | Enum of values | `_Union("USD", "EUR", "GBP")` |
57
+ | `_Nilable(type)` | Nilable wrapper | `_Nilable(String)` |
58
+ | `_Ref(Model)` | Model reference | `_Ref(User)`, `_Ref(Account, lock: true)` |
59
+
60
+ ```ruby
61
+ prop :name, String # any String
62
+ prop :count, Integer # any Integer
63
+ prop :amount, Float # any Float
64
+ prop :amount, BigDecimal # any BigDecimal
65
+ prop :data, Hash # any Hash
66
+ prop :items, Array # any Array
67
+ prop :active, _Boolean # true or false
68
+ prop :role, Symbol # any Symbol
69
+ prop :count, _Integer(1..) # Integer >= 1
70
+ prop :count, _Integer(0..100) # Integer 0–100
71
+ prop :name, _String(length: 1..255) # String with length constraint
72
+ prop :score, _Float(0.0..1.0) # Float in range
73
+ prop :tags, _Array(String) # Array of Strings
74
+ prop :ids, _Array(Integer) # Array of Integers
75
+ prop :matrix, _Array(_Array(Integer)) # nested typed arrays
76
+ prop :currency, _Union("USD", "EUR", "GBP") # enum of values
77
+ prop :id, _Union(String, Integer) # union of types
78
+ prop :label, _Nilable(String) # String or nil
79
+ prop :meta, _Hash(Symbol, String) # Hash with typed keys+values
80
+ prop :pair, _Tuple(String, Integer) # fixed-size typed array
81
+ prop :name, _Frozen(String) # must be frozen
82
+ prop :handler, _Callable # anything responding to .call
83
+ prop :handler, _Interface(:call, :arity) # responds to listed methods
84
+ prop :user, _Ref(User) # Dex-specific: model by instance or ID
85
+ prop :account, _Ref(Account, lock: true) # Dex-specific: with row lock
86
+ prop :title, String, default: "Untitled" # default value
87
+ prop? :note, String # optional (nilable, default: nil)
88
+ ```
89
+
90
+ ### _Ref(Model)
91
+
92
+ Accepts model instances or IDs, coerces IDs via `Model.find(id)`. With `lock: true`, uses `Model.lock.find(id)` (SELECT FOR UPDATE). Instances pass through without re-locking. In serialization (recording, async), stores model ID only.
93
+
94
+ Outside the class body (e.g., in tests), use `Dex::RefType.new(Model)` instead of `_Ref(Model)`.
95
+
96
+ ---
97
+
98
+ ## Contract
99
+
100
+ Optional declarations documenting intent and catching mistakes at runtime.
101
+
102
+ **`success(type)`** — validates return value (`nil` always allowed; raises `ArgumentError` on mismatch):
103
+
104
+ ```ruby
105
+ success _Ref(User) # perform must return a User (or nil)
106
+ ```
107
+
108
+ **`error(*codes)`** — restricts which codes `error!`/`assert!` accept (raises `ArgumentError` on undeclared):
109
+
110
+ ```ruby
111
+ error :email_taken, :invalid_email
112
+ ```
113
+
114
+ Both inherit from parent class. Without `error` declaration, any code is accepted.
115
+
116
+ **Introspection:** `MyOp.contract` returns a frozen `Data` with `params`, `success`, `errors` fields. Supports pattern matching and `to_h`.
117
+
118
+ ---
119
+
120
+ ## Flow Control
121
+
122
+ All three halt execution immediately via non-local exit (work from `perform`, helpers, and callbacks).
123
+
124
+ **`error!(code, message = nil, details: nil)`** — halt with failure, roll back transaction, raise `Dex::Error`:
125
+
126
+ ```ruby
127
+ error!(:not_found, "User not found")
128
+ error!(:validation_failed, details: { field: "email" })
129
+ ```
130
+
131
+ **`success!(value = nil, **attrs)`** — halt with success, commit transaction:
132
+
133
+ ```ruby
134
+ success!(user) # return value early
135
+ success!(name: "John", age: 30) # kwargs become Hash
136
+ ```
137
+
138
+ **`assert!(code, &block)` / `assert!(value, code)`** — returns value if truthy, otherwise `error!(code)`:
139
+
140
+ ```ruby
141
+ user = assert!(:not_found) { User.find_by(id: id) }
142
+ assert!(user.active?, :inactive)
143
+ ```
144
+
145
+ **Dex::Error** has `code` (Symbol), `message` (String, defaults to code.to_s), `details` (any). Pattern matching:
146
+
147
+ ```ruby
148
+ begin
149
+ CreateUser.call(email: "bad", name: "A")
150
+ rescue Dex::Error => e
151
+ case e
152
+ in {code: :not_found} then handle_not_found
153
+ in {code: :validation_failed, details: {field:}} then handle_field(field)
154
+ end
155
+ end
156
+ ```
157
+
158
+ **Key differences:** `error!`/`assert!` roll back transaction, skip `after` callbacks and recording. `success!` commits, runs `after` callbacks, records normally.
159
+
160
+ ---
161
+
162
+ ## Safe Execution (Ok/Err)
163
+
164
+ `.safe.call` wraps results instead of raising. Only catches `Dex::Error` — other exceptions propagate normally.
165
+
166
+ ```ruby
167
+ result = CreateUser.new(email: "a@b.com", name: "Alice").safe.call
168
+
169
+ # Ok
170
+ result.ok? # => true
171
+ result.value # => User instance (also: result.name delegates to value)
172
+
173
+ # Err
174
+ result.error? # => true
175
+ result.code # => :email_taken
176
+ result.message # => "email_taken"
177
+ result.details # => nil or Hash
178
+ result.value! # re-raises Dex::Error
179
+ ```
180
+
181
+ **Pattern matching:**
182
+
183
+ ```ruby
184
+ case CreateUser.new(email: "a@b.com", name: "Alice").safe.call
185
+ in Dex::Ok(name:) then puts "Created #{name}"
186
+ in Dex::Err(code: :email_taken) then puts "Already exists"
187
+ end
188
+ ```
189
+
190
+ `include Dex::Match` to use `Ok`/`Err` without `Dex::` prefix.
191
+
192
+ ---
193
+
194
+ ## Rescue Mapping
195
+
196
+ Map exceptions to structured `Dex::Error` codes — eliminates begin/rescue boilerplate:
197
+
198
+ ```ruby
199
+ class ChargeCard < Dex::Operation
200
+ rescue_from Stripe::CardError, as: :card_declined
201
+ rescue_from Stripe::RateLimitError, as: :rate_limited
202
+ rescue_from Net::OpenTimeout, Net::ReadTimeout, as: :timeout
203
+ rescue_from Stripe::APIError, as: :provider_error, message: "Stripe is down"
204
+
205
+ def perform
206
+ Stripe::Charge.create(amount: amount, source: token)
207
+ end
208
+ end
209
+ ```
210
+
211
+ - `as:` (required): error code Symbol. `message:` (optional): overrides exception message
212
+ - Original exception preserved in `err.details[:original]`
213
+ - Subclass exceptions match parent handlers; child handlers take precedence
214
+ - Converted errors trigger transaction rollback and work with `.safe` (consistent with `error!`)
215
+
216
+ ---
217
+
218
+ ## Callbacks
219
+
220
+ ```ruby
221
+ class ProcessOrder < Dex::Operation
222
+ before :validate_stock # symbol → instance method
223
+ before -> { log("starting") } # lambda (instance_exec'd)
224
+ after :send_confirmation # runs after successful perform
225
+ around :with_timing # wraps everything, must yield
226
+
227
+ def validate_stock
228
+ error!(:out_of_stock) unless in_stock?
229
+ end
230
+
231
+ def with_timing
232
+ start = Time.now
233
+ yield
234
+ puts Time.now - start
235
+ end
236
+ end
237
+ ```
238
+
239
+ - **Order:** `around` wraps → `before` → `perform` → `after`
240
+ - `around` with proc/lambda: receives continuation arg, call `cont.call`
241
+ - `before` calling `error!` stops everything; `after` skipped on error
242
+ - Callbacks run **inside** the transaction — errors trigger rollback
243
+ - Inheritance: parent callbacks run first, then child
244
+
245
+ ---
246
+
247
+ ## Transactions
248
+
249
+ Operations run inside database transactions by default. All changes roll back on error. Nested operations share the outer transaction.
250
+
251
+ ```ruby
252
+ transaction false # disable
253
+ transaction :mongoid # adapter override (default: auto-detect AR → Mongoid)
254
+ ```
255
+
256
+ Child classes can re-enable: `transaction true`.
257
+
258
+ ---
259
+
260
+ ## Advisory Locking
261
+
262
+ Mutual exclusion via database advisory locks (requires `with_advisory_lock` gem). Wraps **outside** the transaction.
263
+
264
+ ```ruby
265
+ advisory_lock { "pay:#{charge_id}" } # dynamic key from props
266
+ advisory_lock "daily-report" # static key
267
+ advisory_lock "report", timeout: 5 # with timeout (seconds)
268
+ advisory_lock # class name as key
269
+ advisory_lock :compute_key # instance method
270
+ ```
271
+
272
+ On timeout: raises `Dex::Error(code: :lock_timeout)`. Works with `.safe`.
273
+
274
+ ---
275
+
276
+ ## Async Execution
277
+
278
+ Enqueue as background jobs (requires ActiveJob):
279
+
280
+ ```ruby
281
+ CreateUser.new(email: "a@b.com", name: "Alice").async.call
282
+ CreateUser.new(email: "a@b.com", name: "Alice").async(queue: "urgent").call
283
+ CreateUser.new(email: "a@b.com", name: "Alice").async(in: 5.minutes).call
284
+ CreateUser.new(email: "a@b.com", name: "Alice").async(at: 1.hour.from_now).call
285
+ ```
286
+
287
+ Class-level defaults: `async queue: "mailers"`. Runtime options override.
288
+
289
+ Props serialize/deserialize automatically (Date, Time, BigDecimal, Symbol, `_Ref` — all handled). Non-serializable props raise `ArgumentError` at enqueue time.
290
+
291
+ ---
292
+
293
+ ## Recording
294
+
295
+ Record execution to database. Requires `Dex.configure { |c| c.record_class = OperationRecord }`.
296
+
297
+ ```ruby
298
+ create_table :operation_records do |t|
299
+ t.string :name # Required: operation class name
300
+ t.jsonb :params # Optional: serialized props
301
+ t.jsonb :response # Optional: serialized result
302
+ t.string :status # Optional: pending/running/done/failed (for async)
303
+ t.string :error # Optional: error code on failure
304
+ t.datetime :performed_at # Optional
305
+ t.timestamps
306
+ end
307
+ ```
308
+
309
+ Control per-operation:
310
+
311
+ ```ruby
312
+ record false # disable entirely
313
+ record response: false # params only
314
+ record params: false # response only
315
+ ```
316
+
317
+ Recording happens inside the transaction — rolled back on `error!`/`assert!`. Missing columns silently skipped.
318
+
319
+ When both async and recording are enabled, Dexkit automatically stores only the record ID in the job payload instead of full params. The record tracks `status` (pending → running → done/failed) and `error` (code or exception class name).
320
+
321
+ ---
322
+
323
+ ## Configuration
324
+
325
+ ```ruby
326
+ # config/initializers/dexkit.rb
327
+ Dex.configure do |config|
328
+ config.record_class = OperationRecord # model for recording (default: nil)
329
+ config.transaction_adapter = nil # auto-detect (default); or :active_record / :mongoid
330
+ end
331
+ ```
332
+
333
+ All DSL methods validate arguments at declaration time — typos and wrong types raise `ArgumentError` immediately (e.g., `error "string"`, `async priority: 5`, `transaction :redis`).
334
+
335
+ ---
336
+
337
+ ## Testing
338
+
339
+ ```ruby
340
+ # test/test_helper.rb
341
+ require "dex/test_helpers"
342
+
343
+ class Minitest::Test
344
+ include Dex::TestHelpers
345
+ end
346
+ ```
347
+
348
+ Not autoloaded — stays out of production. TestLog and stubs are auto-cleared in `setup`.
349
+
350
+ ### Subject & Execution
351
+
352
+ ```ruby
353
+ class CreateUserTest < Minitest::Test
354
+ include Dex::TestHelpers
355
+
356
+ testing CreateUser # default for all helpers
357
+
358
+ def test_example
359
+ result = call_operation(email: "a@b.com", name: "Alice") # => Ok or Err (safe)
360
+ value = call_operation!(email: "a@b.com", name: "Alice") # => raw value or raises
361
+ end
362
+ end
363
+ ```
364
+
365
+ All helpers accept an explicit class as first arg: `call_operation(OtherOp, name: "x")`.
366
+
367
+ ### Result Assertions
368
+
369
+ ```ruby
370
+ assert_ok result # passes if Ok
371
+ assert_ok result, user # checks value equality
372
+ assert_ok(result) { |val| assert val } # yields value
373
+
374
+ assert_err result # passes if Err
375
+ assert_err result, :not_found # checks code
376
+ assert_err result, :fail, message: "x" # checks message (String or Regex)
377
+ assert_err result, :fail, details: { field: "email" }
378
+ assert_err(result, :fail) { |err| assert err } # yields Dex::Error
379
+
380
+ refute_ok result
381
+ refute_err result
382
+ refute_err result, :not_found # Ok OR different code
383
+ ```
384
+
385
+ ### One-Liner Assertions
386
+
387
+ Call + assert in one step:
388
+
389
+ ```ruby
390
+ assert_operation(email: "a@b.com", name: "Alice") # Ok
391
+ assert_operation(CreateUser, email: "a@b.com", name: "Alice") # explicit class
392
+ assert_operation(email: "a@b.com", name: "Alice", returns: user) # check value
393
+
394
+ assert_operation_error(:invalid_email, email: "bad", name: "A")
395
+ assert_operation_error(CreateUser, :email_taken, email: "taken@b.com", name: "A")
396
+ ```
397
+
398
+ ### Contract Assertions
399
+
400
+ ```ruby
401
+ assert_params(:name, :email, :role) # exhaustive names (order-independent)
402
+ assert_params(name: String, email: String) # with types
403
+ assert_accepts_param(:name) # subset check
404
+
405
+ assert_success_type(Dex::RefType.new(User)) # use Dex::RefType outside class body
406
+ assert_error_codes(:email_taken, :invalid_email)
407
+
408
+ assert_contract(
409
+ params: [:name, :email, :role],
410
+ success: Dex::RefType.new(User),
411
+ errors: [:email_taken, :invalid_email]
412
+ )
413
+ ```
414
+
415
+ ### Param Validation
416
+
417
+ ```ruby
418
+ assert_invalid_params(name: 123) # asserts Literal::TypeError
419
+ assert_valid_params(email: "a@b.com", name: "Alice") # no error (doesn't call perform)
420
+ ```
421
+
422
+ ### Async & Transaction Assertions
423
+
424
+ Async requires `ActiveJob::TestHelper`:
425
+
426
+ ```ruby
427
+ assert_enqueues_operation(email: "a@b.com", name: "Alice")
428
+ assert_enqueues_operation(CreateUser, email: "a@b.com", name: "Alice", queue: "default")
429
+ refute_enqueues_operation { do_something }
430
+ ```
431
+
432
+ Transaction:
433
+
434
+ ```ruby
435
+ assert_rolls_back(User) { CreateUser.call(email: "bad", name: "A") }
436
+ assert_commits(User) { CreateUser.call(email: "ok@b.com", name: "A") }
437
+ ```
438
+
439
+ ### Batch Assertions
440
+
441
+ ```ruby
442
+ assert_all_succeed(params_list: [
443
+ { email: "a@b.com", name: "A" },
444
+ { email: "b@b.com", name: "B" }
445
+ ])
446
+
447
+ assert_all_fail(code: :invalid_email, params_list: [
448
+ { email: "", name: "A" },
449
+ { email: "no-at", name: "B" }
450
+ ])
451
+ # Also supports message: and details: options
452
+ ```
453
+
454
+ ### Stubbing
455
+
456
+ Replace an operation within a block. Bypasses all wrappers, not recorded in TestLog:
457
+
458
+ ```ruby
459
+ stub_operation(SendWelcomeEmail, returns: "fake") do
460
+ call_operation!(email: "a@b.com", name: "Alice")
461
+ end
462
+
463
+ stub_operation(SendWelcomeEmail, error: :not_found) do
464
+ # raises Dex::Error(code: :not_found)
465
+ end
466
+
467
+ stub_operation(SendWelcomeEmail, error: { code: :fail, message: "oops" }) do
468
+ # raises Dex::Error with code and message
469
+ end
470
+ ```
471
+
472
+ ### Spying
473
+
474
+ Observe real execution without modifying behavior:
475
+
476
+ ```ruby
477
+ spy_on_operation(SendWelcomeEmail) do |spy|
478
+ CreateUser.call(email: "a@b.com", name: "Alice")
479
+
480
+ spy.called? # => true
481
+ spy.called_once? # => true
482
+ spy.call_count # => 1
483
+ spy.last_result # => Ok or Err
484
+ spy.called_with?(email: "a@b.com") # => true (subset match)
485
+ end
486
+ ```
487
+
488
+ ### TestLog
489
+
490
+ Global log of all operation calls:
491
+
492
+ ```ruby
493
+ Dex::TestLog.calls # all entries
494
+ Dex::TestLog.find(CreateUser) # filter by class
495
+ Dex::TestLog.find(CreateUser, email: "a@b.com") # filter by class + params
496
+ Dex::TestLog.size; Dex::TestLog.empty?; Dex::TestLog.clear!
497
+ Dex::TestLog.summary # human-readable for failure messages
498
+ ```
499
+
500
+ Each entry has: `name`, `operation_class`, `params`, `result` (Ok/Err), `duration`, `caller_location`.
501
+
502
+ ### Complete Test Example
503
+
504
+ ```ruby
505
+ class CreateUserTest < Minitest::Test
506
+ include Dex::TestHelpers
507
+
508
+ testing CreateUser
509
+
510
+ def test_contract
511
+ assert_params(:name, :email, :role)
512
+ assert_success_type(Dex::RefType.new(User))
513
+ assert_error_codes(:email_taken, :invalid_email)
514
+ end
515
+
516
+ def test_creates_user
517
+ result = call_operation(email: "a@b.com", name: "Alice")
518
+ assert_ok(result) { |user| assert_equal "Alice", user.name }
519
+ end
520
+
521
+ def test_one_liner
522
+ assert_operation(email: "a@b.com", name: "Alice")
523
+ end
524
+
525
+ def test_rejects_bad_email
526
+ assert_operation_error(:invalid_email, email: "bad", name: "A")
527
+ end
528
+
529
+ def test_batch_rejects
530
+ assert_all_fail(code: :invalid_email, params_list: [
531
+ { email: "", name: "A" },
532
+ { email: "no-at", name: "B" }
533
+ ])
534
+ end
535
+
536
+ def test_stubs_dependency
537
+ stub_operation(SendWelcomeEmail, returns: true) do
538
+ call_operation!(email: "a@b.com", name: "Alice")
539
+ end
540
+ end
541
+
542
+ def test_spies_on_dependency
543
+ spy_on_operation(SendWelcomeEmail) do |spy|
544
+ call_operation!(email: "a@b.com", name: "Alice")
545
+ assert spy.called_once?
546
+ end
547
+ end
548
+ end
549
+ ```
550
+
551
+ ---
552
+
553
+ **End of reference.**