zizq 0.3.3 → 0.3.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab68c867c5342352c99cccc1c244215a12256c8957be422c38c1446fbcc317e
4
- data.tar.gz: 58651399c191747b0e1fab0b0751e53de4eb415dcb0ae5845b76eb35590cb5eb
3
+ metadata.gz: 3d7eb0e9668c055e6f5b6b8ec6aacb5c09f8df5eedcea038acdb14d164a58acf
4
+ data.tar.gz: d30b95be8bab2eae5100b89fa7957278a20c301538e5630156bf9892739864a3
5
5
  SHA512:
6
- metadata.gz: 4b35bd73a1e4c8c66113b7f1b0c4efbb1eef97e0b304730510a76a35498e6fcc38c15016b53b7507206e558535d07b1a0dbd377d7e5472247f9fffd5ac5a79ec
7
- data.tar.gz: 6fbdbe994579189e68bd37e5ce4167a4c074593609cb7938f411e956c073b11d43c0863e8c8038bb2e77cfe0246263e3c7b55e83d3961f5da99e133164da766a
6
+ metadata.gz: dbe8d111b4083de907b4522ae4f0c110554a0ab5821a544d808c363868602178c15f983140436da6525b96ab30be159ac52309adfb05cb271739a7aee73174cc
7
+ data.tar.gz: 39606af98c372c50147922ba7640dcd9e58d449c63927008abbcc03ac2b926e68d4856764152f51f16fb26554241e801df1fb862542e405a1087bd2213d4eaab
data/README.md CHANGED
@@ -32,13 +32,13 @@ API.
32
32
  Add it to your application's `Gemfile`:
33
33
 
34
34
  ```ruby
35
- gem 'zizq', '~> 0.3.3'
35
+ gem 'zizq', '~> 0.3.5'
36
36
  ```
37
37
 
38
38
  Or install it manually:
39
39
 
40
40
  ```shell
41
- $ gem install zizq -v 0.3.3
41
+ $ gem install zizq -v 0.3.5
42
42
  ```
43
43
 
44
44
  Ruby **3.2.8 or newer** is required. Client and server share version
@@ -221,6 +221,38 @@ Once defined, schedules can be inspected and managed via
221
221
  `Zizq.crontab('maintenance')` — paused/resumed at the schedule level or per
222
222
  entry, and deleted entirely when no longer needed.
223
223
 
224
+ ### Testing
225
+
226
+ Set `c.test_mode = true` in your test helper and Zizq swaps the real
227
+ client out for an in-memory `Zizq::Test::Client` that buffers enqueues
228
+ instead of dispatching them. Tests can then assert on what was
229
+ enqueued and drain the buffer through the configured dispatcher —
230
+ no running server required.
231
+
232
+ ```ruby
233
+ # test/test_helper.rb (or spec/spec_helper.rb)
234
+ Zizq::Test.enable!
235
+
236
+ class ActiveSupport::TestCase
237
+ setup { Zizq::Test.reset! }
238
+ end
239
+
240
+ # In a test
241
+ def test_signup_fans_out
242
+ SignupService.new.run
243
+
244
+ assert Zizq::Test.enqueued?(SendWelcomeEmailJob, user_id: 42)
245
+ assert_equal 2, Zizq::Test.pending_jobs(only_queues: 'emails').size
246
+
247
+ # Drain the buffer through Zizq.configuration.dequeue_middleware
248
+ # (same path the real worker takes — registered middleware runs too).
249
+ Zizq::Test.dispatch_enqueued_jobs
250
+ end
251
+ ```
252
+
253
+ See [Testing](https://zizq.io/docs/clients/ruby/testing.html) for
254
+ full details.
255
+
224
256
  ## Resources
225
257
 
226
258
  * [Ruby Client Docs](https://zizq.io/docs/clients/ruby/)
@@ -59,6 +59,16 @@ module Zizq
59
59
  # a `Resources::Job` and a chain to continue.
60
60
  attr_reader :dequeue_middleware #: Middleware::Chain[Resources::Job, void]
61
61
 
62
+ # When truthy, `Zizq.client` lazily resolves to a
63
+ # `Zizq::Test::Client` that buffers enqueues in memory rather than
64
+ # dispatching to a real server. Useful inside test suites — set it
65
+ # once in your test helper and the rest of the app's code uses
66
+ # `Zizq.enqueue` / `Zizq.enqueue_bulk` unchanged. Read operations
67
+ # (`Zizq.query`, `Zizq.queues`, `Client#get_job`, etc.) raise
68
+ # rather than silently returning empty results, so missing test
69
+ # setup is obvious.
70
+ attr_accessor :test_mode #: bool
71
+
62
72
  def initialize #: () -> void
63
73
  @url = "http://localhost:7890"
64
74
  @format = :msgpack
@@ -67,6 +77,7 @@ module Zizq
67
77
  @worker = nil
68
78
  @read_timeout = 30
69
79
  @stream_idle_timeout = 30
80
+ @test_mode = false
70
81
  @enqueue_middleware = Middleware::Chain.new(Identity.new)
71
82
  @dequeue_middleware = Middleware::Chain.new(Zizq::Job)
72
83
  end
@@ -157,8 +157,8 @@ module Zizq
157
157
  if backoff
158
158
  params[:backoff] = {
159
159
  exponent: backoff[:exponent].to_f,
160
- base_ms: (backoff[:base].to_f * 1000).to_f,
161
- jitter_ms: (backoff[:jitter].to_f * 1000).to_f
160
+ base_ms: (backoff[:base].to_f * 1000).to_i,
161
+ jitter_ms: (backoff[:jitter].to_f * 1000).to_i
162
162
  }
163
163
  end
164
164
 
@@ -0,0 +1,332 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ module Zizq
8
+ module Test
9
+ # A `Zizq::Client` stand-in for use in test suites.
10
+ #
11
+ # Buffers `enqueue` / `enqueue_bulk` calls in memory and returns
12
+ # synthetic `Resources::Job` instances (with generated ids) so that
13
+ # callers depending on the regular client's return contract don't
14
+ # need to special-case test mode.
15
+ #
16
+ # Read operations (`get_queues`, `list_jobs`, `count_jobs`, …) are
17
+ # explicitly not supported in test mode and raise `NotSupported`.
18
+ # Tests that need those should either run against a real server or
19
+ # stub at a higher level.
20
+ #
21
+ # Activated indirectly via `Zizq.configuration.test_mode = true` —
22
+ # `Zizq.client` then lazily builds a `Test::Client` instead of a
23
+ # real `Client`.
24
+ class Client < Zizq::Client
25
+ # Raised when test-mode code reaches an operation that isn't
26
+ # supported (queries, queue listing, worker streams, etc.).
27
+ class NotSupported < Zizq::Error; end
28
+
29
+ # Length of a real scru128 id in its base-32 representation.
30
+ # Synthetic test ids are sized to match (`test` prefix + zero
31
+ # padded counter) so they fit anywhere a real id would.
32
+ ID_LENGTH = 25
33
+ ID_PREFIX = "test"
34
+
35
+ # The canonical Zizq lifecycle states. We mirror these so the
36
+ # `status` on a buffered job reflects what the real server would
37
+ # report. Test mode never retries — `in_flight` only ever
38
+ # transitions to `completed` or `dead`.
39
+ STATUS_SCHEDULED = "scheduled"
40
+ STATUS_READY = "ready"
41
+ STATUS_IN_FLIGHT = "in_flight"
42
+ STATUS_COMPLETED = "completed"
43
+ STATUS_DEAD = "dead"
44
+
45
+ # Default `filter:` lambda — passes every job. Named so the
46
+ # filter pipeline is always callable without nil-checking.
47
+ PASS_ALL_FILTER = ->(_job) { true } #: ^(Resources::Job) -> bool
48
+
49
+ # Paired view of a single enqueue: the original `EnqueueRequest`
50
+ # (with full submission metadata — `unique_key`, `unique_while`,
51
+ # retry config, etc.), the synthetic `Resources::Job` returned
52
+ # to callers, and the data hash that backs the job (so we can
53
+ # mutate its status through the lifecycle).
54
+ Entry = Struct.new(:request, :job, :data, keyword_init: true)
55
+
56
+ def initialize #: () -> void
57
+ # Skip the parent's HTTP setup — we don't open connections in
58
+ # test mode. The parent's @http and friends stay nil; methods
59
+ # that would touch them are overridden below.
60
+
61
+ @entries = [] #: Array[Entry]
62
+ @mutex = Mutex.new
63
+ end
64
+
65
+ # All buffered jobs, in submission order, optionally filtered.
66
+ # See `apply_filters` for the filter kwargs.
67
+ def enqueued_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
68
+ @mutex.synchronize { apply_filters(@entries, **filters).map(&:job) }
69
+ end
70
+
71
+ # Original `EnqueueRequest`s in submission order. Useful when a
72
+ # test needs metadata that doesn't survive onto `Resources::Job`
73
+ # (`unique_key`, `unique_while`, `delay` before `ready_at`
74
+ # resolution, etc.). Same filter kwargs as `enqueued_jobs`.
75
+ def enqueued_requests(**filters) #: (**untyped) -> Array[EnqueueRequest]
76
+ @mutex.synchronize { apply_filters(@entries, **filters).map(&:request) }
77
+ end
78
+
79
+ # Jobs awaiting dispatch — `ready` and `scheduled` entries.
80
+ # `pending_jobs` is the set `drain` would attempt to run on its
81
+ # next call (modulo `ready_at` for `scheduled` entries).
82
+ def pending_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
83
+ filter_by_status([STATUS_READY, STATUS_SCHEDULED], **filters)
84
+ end
85
+
86
+ def in_flight_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
87
+ filter_by_status([STATUS_IN_FLIGHT], **filters)
88
+ end
89
+
90
+ def completed_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
91
+ filter_by_status([STATUS_COMPLETED], **filters)
92
+ end
93
+
94
+ def dead_jobs(**filters) #: (**untyped) -> Array[Resources::Job]
95
+ filter_by_status([STATUS_DEAD], **filters)
96
+ end
97
+
98
+ # Reset the buffer. Called between tests via `Zizq::Test.reset!`.
99
+ def clear! #: () -> void
100
+ @mutex.synchronize { @entries.clear }
101
+ end
102
+
103
+ def close #: () -> void
104
+ end
105
+
106
+ # Dispatch every runnable entry (status `ready`, or `scheduled`
107
+ # with `ready_at` already elapsed) through the configured
108
+ # dequeue middleware chain (same path the real worker uses, so
109
+ # any registered middlewares run in tests too), looping until no
110
+ # more match the filters. Re-enqueues during dispatch fall
111
+ # through the loop naturally — they get drained too unless they
112
+ # fall outside the filter set.
113
+ #
114
+ # The per-iteration snapshot is taken under the mutex and marked
115
+ # `in_flight` atomically. Dispatch happens outside the mutex so
116
+ # handlers can re-enter the client without deadlocking. On
117
+ # success the entry moves to `completed`; on a raised exception
118
+ # it moves to `dead` and the exception re-raises (matching
119
+ # ActiveJob's `perform_enqueued_jobs` + Sidekiq's `drain`).
120
+ def drain(**filters) #: (**untyped) -> Integer
121
+ total = 0
122
+ loop do
123
+ snapshot = take_runnable_snapshot(**filters)
124
+ break if snapshot.empty?
125
+
126
+ snapshot.each { |entry| dispatch_entry(entry) }
127
+ total += snapshot.size
128
+ end
129
+ total
130
+ end
131
+
132
+ # @rbs override
133
+ def enqueue(queue:,
134
+ type:,
135
+ payload:,
136
+ priority: nil,
137
+ ready_at: nil,
138
+ retry_limit: nil,
139
+ backoff: nil,
140
+ retention: nil,
141
+ unique_key: nil,
142
+ unique_while: nil)
143
+ req = EnqueueRequest.new(
144
+ queue:,
145
+ type:,
146
+ payload:,
147
+ priority:,
148
+ ready_at:,
149
+ retry_limit:,
150
+ backoff:,
151
+ retention:,
152
+ unique_key:,
153
+ unique_while:,
154
+ )
155
+ @mutex.synchronize { record_unsynchronized(req) }.job
156
+ end
157
+
158
+ # @rbs override
159
+ def enqueue_bulk(jobs:)
160
+ @mutex.synchronize do
161
+ jobs.map do |params|
162
+ req = EnqueueRequest.new(
163
+ queue: params[:queue],
164
+ type: params[:type],
165
+ payload: params[:payload],
166
+ priority: params[:priority],
167
+ ready_at: params[:ready_at],
168
+ retry_limit: params[:retry_limit],
169
+ backoff: params[:backoff],
170
+ retention: params[:retention],
171
+ unique_key: params[:unique_key],
172
+ unique_while: params[:unique_while],
173
+ )
174
+ record_unsynchronized(req).job
175
+ end
176
+ end
177
+ end
178
+
179
+ # All read / mutation / streaming operations are deliberately
180
+ # unimplemented.
181
+ #
182
+ # Raising loudly beats silently returning empty results that
183
+ # hide missing test setup.
184
+ %i[
185
+ get_queues
186
+ list_jobs
187
+ count_jobs
188
+ get_job
189
+ delete_job
190
+ delete_all_jobs
191
+ update_job
192
+ update_all_jobs
193
+ take_jobs
194
+ get_error
195
+ list_errors
196
+ health
197
+ server_version
198
+ ].each do |method_name|
199
+ define_method(method_name) do |*, **, &_|
200
+ Kernel.raise(
201
+ NotSupported,
202
+ "Zizq::Test::Client##{method_name} is not supported in test mode. " \
203
+ "Test mode buffers enqueues only — point at a real server, or stub the call."
204
+ )
205
+ end
206
+ end
207
+
208
+ private
209
+
210
+ def record_unsynchronized(req) #: (EnqueueRequest) -> Entry
211
+ # Serialized format: ready_at is integer milliseconds.
212
+ # `req.ready_at` (from `to_enqueue_params`) is fractional
213
+ # seconds; convert here so `Resources::Job#ready_at`'s
214
+ # ms -> seconds round-trip produces the same value the
215
+ # caller passed in. When the client omits ready_at the server
216
+ # assigns `now`, so we do the same.
217
+ now_ms = (Time.now.to_f * 1000).to_i
218
+ ready_at_ms = req.ready_at ? (req.ready_at.to_f * 1000).to_i : now_ms
219
+
220
+ data = {
221
+ "id" => synthetic_id(@entries.size + 1),
222
+ "queue" => req.queue,
223
+ "type" => req.type,
224
+ "payload" => req.payload,
225
+ "priority" => req.priority,
226
+ "ready_at" => ready_at_ms,
227
+ "retry_limit" => req.retry_limit,
228
+ "status" => ready_at_ms > now_ms ? STATUS_SCHEDULED : STATUS_READY,
229
+ }
230
+ entry = Entry.new(request: req, job: Resources::Job.new(self, data), data: data)
231
+ @entries << entry
232
+ entry
233
+ end
234
+
235
+ def synthetic_id(counter) #: (Integer) -> String
236
+ "#{ID_PREFIX}#{counter.to_s.rjust(ID_LENGTH - ID_PREFIX.length, '0')}"
237
+ end
238
+
239
+ # Returns entries matching every named filter AND the predicate.
240
+ # All filter kwargs are optional; unset means "don't filter on
241
+ # this axis." Callers must hold `@mutex` — public accessors do
242
+ # so via `synchronize` before calling.
243
+ #
244
+ # * `only_queues:` / `except_queues:` — String, Array of Strings.
245
+ # * `only_types:` / `except_types:` — String, Class, or Array
246
+ # of those. Class names are matched against the wire-format
247
+ # `type` string via `.to_s`.
248
+ # * `filter:` — a lambda receiving a `Resources::Job`, returning
249
+ # truthy to keep. Defaults to `PASS_ALL_FILTER`.
250
+ #
251
+ # `only_*` and `except_*` AND together with the predicate.
252
+ def apply_filters(entries,
253
+ only_queues: nil,
254
+ except_queues: nil,
255
+ only_types: nil,
256
+ except_types: nil,
257
+ filter: PASS_ALL_FILTER) #: (Array[Entry], **untyped) -> Array[Entry]
258
+ only_queues = normalize_filter(only_queues)
259
+ except_queues = normalize_filter(except_queues)
260
+ only_types = normalize_filter(only_types)
261
+ except_types = normalize_filter(except_types)
262
+
263
+ entries.select do |entry|
264
+ queue = entry.data["queue"] #: String
265
+ type = entry.data["type"] #: String
266
+
267
+ (only_queues.empty? || only_queues.include?(queue)) &&
268
+ (except_queues.empty? || !except_queues.include?(queue)) &&
269
+ (only_types.empty? || only_types.include?(type)) &&
270
+ (except_types.empty? || !except_types.include?(type)) &&
271
+ filter.call(entry.job)
272
+ end
273
+ end
274
+
275
+ def filter_by_status(statuses, **filters) #: (Array[String], **untyped) -> Array[Resources::Job]
276
+ @mutex.synchronize do
277
+ apply_filters(@entries, **filters)
278
+ .select { |e| statuses.include?(e.data["status"]) }
279
+ .map(&:job)
280
+ end
281
+ end
282
+
283
+ # Lock, find runnable entries matching the filters, flip each
284
+ # to in_flight, return the snapshot. Holding the mutex through
285
+ # this is fine because we don't call user code — dispatch
286
+ # happens outside.
287
+ def take_runnable_snapshot(**filters) #: (**untyped) -> Array[Entry]
288
+ now_ms = (Time.now.to_f * 1000).to_i
289
+ @mutex.synchronize do
290
+ apply_filters(@entries, **filters)
291
+ .select { |e| runnable?(e, now_ms) }
292
+ .each { |entry| entry.data["status"] = STATUS_IN_FLIGHT }
293
+ end
294
+ end
295
+
296
+ def runnable?(entry, now_ms) #: (Entry, Integer) -> bool
297
+ case entry.data["status"]
298
+ when STATUS_READY then true
299
+ when STATUS_SCHEDULED then entry.data["ready_at"] <= now_ms
300
+ else false
301
+ end
302
+ end
303
+
304
+ # Accept a Class, String, or Array of those; emit an Array of
305
+ # Strings. Class names match the API's `type` string.
306
+ def normalize_filter(value) #: ((String | Class | Array[String | Class])?) -> Array[String]
307
+ case value
308
+ when nil then []
309
+ when Array then value.map { |x| x.to_s }
310
+ else [value.to_s]
311
+ end
312
+ end
313
+
314
+ # Mark the entry in_flight, dispatch through the full dequeue
315
+ # middleware chain (same path the real worker uses, so any
316
+ # registered middlewares run in tests too), settle to
317
+ # completed or dead. A raised exception re-raises after
318
+ # recording — same observable behaviour as Rails'
319
+ # `perform_enqueued_jobs`.
320
+ def dispatch_entry(entry) #: (Entry) -> void
321
+ entry.data["status"] = STATUS_IN_FLIGHT
322
+ begin
323
+ Zizq.configuration.dequeue_middleware.call(entry.job)
324
+ entry.data["status"] = STATUS_COMPLETED
325
+ rescue
326
+ entry.data["status"] = STATUS_DEAD
327
+ raise
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
data/lib/zizq/test.rb ADDED
@@ -0,0 +1,190 @@
1
+ # Copyright (c) 2026 Chris Corbyn <chris@zizq.io>
2
+ # Licensed under the MIT License. See LICENSE file for details.
3
+
4
+ # rbs_inline: enabled
5
+ # frozen_string_literal: true
6
+
7
+ module Zizq
8
+ # Test-mode helpers. Activated by setting `c.test_mode = true` in a
9
+ # `Zizq.configure` block; `Zizq.client` then lazily resolves to a
10
+ # `Zizq::Test::Client` that buffers enqueues instead of dispatching.
11
+ #
12
+ # Typical use in a test helper:
13
+ #
14
+ # Zizq.configure do |c|
15
+ # c.test_mode = true
16
+ # end
17
+ #
18
+ # class MyTestCase
19
+ # setup { Zizq::Test.reset! }
20
+ # end
21
+ #
22
+ # In a test:
23
+ #
24
+ # def test_signup_fans_out
25
+ # # Default buffered mode — assert what was enqueued.
26
+ # SignupService.new.run
27
+ # assert_equal 2, Zizq::Test.client.pending_jobs.size
28
+ #
29
+ # # Drain whatever's pending (handler re-enqueues fall through
30
+ # # naturally — drain loops until pending is empty).
31
+ # Zizq::Test.dispatch_enqueued_jobs
32
+ #
33
+ # # Block form: run the work, then drain. Matches ActiveJob's
34
+ # # `perform_enqueued_jobs do ... end`.
35
+ # Zizq::Test.dispatch_enqueued_jobs { SignupService.new.run }
36
+ #
37
+ # # Filter by queue and/or type when only a subset should fire.
38
+ # Zizq::Test.dispatch_enqueued_jobs(queue: "emails")
39
+ # Zizq::Test.dispatch_enqueued_jobs(type: SendEmailJob)
40
+ # end
41
+ module Test
42
+ autoload :Client, "zizq/test/client"
43
+
44
+ # Switch Zizq into test mode. After this, `Zizq.client` resolves
45
+ # to a `Zizq::Test::Client` that buffers enqueues in memory.
46
+ # Typically called once in a test helper.
47
+ def self.enable! #: () -> void
48
+ Zizq.configure { |c| c.test_mode = true }
49
+ end
50
+
51
+ # Switch back to the real client. The buffered state is dropped
52
+ # along with the test client (the next `Zizq.client` access
53
+ # builds a fresh `Zizq::Client`).
54
+ def self.disable! #: () -> void
55
+ Zizq.configure { |c| c.test_mode = false }
56
+ end
57
+
58
+ # The active test client. Raises if test mode is not enabled —
59
+ # better to fail loudly than return a stale or wrong client.
60
+ def self.client #: () -> Client
61
+ unless Zizq.configuration.test_mode
62
+ raise Client::NotSupported,
63
+ "Zizq.configuration.test_mode is not enabled; Zizq::Test.client has nothing to manage."
64
+ end
65
+ Zizq.client #: Client
66
+ end
67
+
68
+ # Reset buffered state between tests. Keeps the configured
69
+ # `test_mode` flag.
70
+ def self.reset! #: () -> void
71
+ client.clear!
72
+ end
73
+
74
+ # Dispatch pending jobs through the configured dequeue middleware
75
+ # chain (`Zizq.configuration.dequeue_middleware` — same path the
76
+ # real worker uses, so any registered middlewares run in tests
77
+ # too), looping until no more pending entries match the filters.
78
+ #
79
+ # * No block: drain whatever's pending now.
80
+ # * With block: yield first (test code enqueues), then drain.
81
+ # * `only_queues:` / `except_queues:` — String or Array of Strings.
82
+ # * `only_types:` / `except_types:` — String, Class, or Array of
83
+ # those (Class names match the serialized-format `type` via `.to_s`,
84
+ # so passing an ActiveJob class works directly).
85
+ # * `filter:` — a lambda `->(job)` returning truthy to keep an
86
+ # entry. Defaults to "pass all". Combines with the named
87
+ # filters via AND.
88
+ #
89
+ # Returns the number of jobs dispatched. A block exception
90
+ # propagates without draining; a handler exception during drain
91
+ # transitions that entry to `dead` and re-raises.
92
+ def self.dispatch_enqueued_jobs(**filters, &block) #: (**untyped) ?{ () -> void } -> Integer
93
+ yield if block
94
+ client.drain(**filters)
95
+ end
96
+
97
+ # Convenience proxies onto the active test client, so test code
98
+ # can read `Zizq::Test.pending_jobs(only_queues: "emails")`
99
+ # instead of routing through `Zizq::Test.client.pending_jobs(...)`.
100
+ # Each forwards `**filters` to the corresponding Client accessor;
101
+ # see `Test::Client` for the supported filter kwargs.
102
+ #
103
+ # All accept the same filter kwargs as `#dispatch_enqueued_jobs`.
104
+ %i[
105
+ enqueued_jobs
106
+ enqueued_requests
107
+ pending_jobs
108
+ in_flight_jobs
109
+ completed_jobs
110
+ dead_jobs
111
+ ].each do |name|
112
+ define_singleton_method(name) do |**filters|
113
+ client.public_send(name, **filters)
114
+ end
115
+ end
116
+
117
+ # Was a job of `job_class` enqueued (optionally with matching
118
+ # args)?
119
+ #
120
+ # Zizq::Test.enqueued?(SendEmailJob) # any args
121
+ # Zizq::Test.enqueued?(SendEmailJob, 42, template: "welcome") # exact args
122
+ #
123
+ # With no args, matches by class/type only. With args/kwargs,
124
+ # uses the class's own `zizq_serialize` to compute the expected
125
+ # payload — so it works for both `Zizq::Job` and AJ classes
126
+ # (AJ's volatile fields like `job_id` are ignored, only the
127
+ # `arguments` subset is compared).
128
+ #
129
+ # For anything fuzzier (matchers, subset matching, custom
130
+ # predicates), drop down to
131
+ # `client.enqueued_jobs(only_types: ..., filter: ->(job) { ... })`.
132
+ def self.enqueued?(job_class, *args, **kwargs) #: (Class, *untyped, **untyped) -> bool
133
+ enqueued_count(job_class, *args, **kwargs) > 0
134
+ end
135
+
136
+ # How many times was a job of `job_class` enqueued (optionally
137
+ # with matching args)? Same argument semantics as `#enqueued?`.
138
+ def self.enqueued_count(job_class, *args, **kwargs) #: (Class, *untyped, **untyped) -> Integer
139
+ type = job_class.to_s
140
+ return enqueued_raw_count(type: type) if args.empty? && kwargs.empty?
141
+
142
+ unless job_class.respond_to?(:zizq_serialize)
143
+ raise ArgumentError,
144
+ "#{job_class} doesn't implement zizq_serialize — " \
145
+ "include Zizq::Job or extend Zizq::ActiveJobConfig, " \
146
+ "or use Zizq::Test.enqueued_raw? for raw enqueues."
147
+ end
148
+
149
+ expected = job_class.zizq_serialize(*args, **kwargs)
150
+ client.enqueued_jobs(only_types: type).count do |job|
151
+ payloads_equivalent?(expected, job.payload)
152
+ end
153
+ end
154
+
155
+ # Was a raw job (queue + type + payload) enqueued? Each kwarg is
156
+ # optional; unspecified means "don't filter on this axis."
157
+ #
158
+ # Zizq::Test.enqueued_raw?(type: "send_email")
159
+ # Zizq::Test.enqueued_raw?(type: "send_email", payload: { user_id: 42 })
160
+ # Zizq::Test.enqueued_raw?(queue: "emails", type: "send_email")
161
+ def self.enqueued_raw?(queue: nil, type: nil, payload: nil) #: (?queue: String?, ?type: String?, ?payload: untyped) -> bool
162
+ enqueued_raw_count(queue: queue, type: type, payload: payload) > 0
163
+ end
164
+
165
+ # How many raw jobs match (queue + type + payload)? Same
166
+ # argument semantics as `#enqueued_raw?`.
167
+ def self.enqueued_raw_count(queue: nil, type: nil, payload: nil) #: (?queue: String?, ?type: String?, ?payload: untyped) -> Integer
168
+ filters = {}
169
+ filters[:only_queues] = queue if queue
170
+ filters[:only_types] = type if type
171
+ filters[:filter] = ->(job) { job.payload == payload } unless payload.nil?
172
+ client.enqueued_jobs(**filters).size
173
+ end
174
+
175
+ # Heuristic payload comparison that handles both `Zizq::Job`'s
176
+ # serialized format (`{"args" => [...], "kwargs" => {...}}`) and
177
+ # ActiveJob's (`{"job_class" => ..., "arguments" => [...],
178
+ # "job_id" => ..., "enqueued_at" => ..., ...}`). The AJ shape
179
+ # always has an `"arguments"` key while `Zizq::Job`'s doesn't, so
180
+ # we use that to pick whether to compare the full hash or only the
181
+ # `arguments` subset (dropping AJ's volatile per-enqueue fields).
182
+ def self.payloads_equivalent?(expected, actual) #: (untyped, untyped) -> bool
183
+ if expected.is_a?(Hash) && expected.key?("arguments")
184
+ actual.is_a?(Hash) && actual["arguments"] == expected["arguments"]
185
+ else
186
+ actual == expected
187
+ end
188
+ end
189
+ end
190
+ end
data/lib/zizq/version.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  # frozen_string_literal: true
6
6
 
7
7
  module Zizq
8
- VERSION = "0.3.3" #: String
8
+ VERSION = "0.3.5" #: String
9
9
  end
data/lib/zizq.rb CHANGED
@@ -29,6 +29,7 @@ module Zizq
29
29
  autoload :Lifecycle, "zizq/lifecycle"
30
30
  autoload :Query, "zizq/query"
31
31
  autoload :Resources, "zizq/resources"
32
+ autoload :Test, "zizq/test"
32
33
  autoload :TlsConfiguration, "zizq/tls_configuration"
33
34
  autoload :Worker, "zizq/worker"
34
35
  autoload :WorkerConfiguration, "zizq/worker_configuration"
@@ -74,19 +75,27 @@ module Zizq
74
75
  #
75
76
  # The client is memoized so that persistent HTTP connections are reused
76
77
  # across calls, reducing TCP connection overhead.
78
+ #
79
+ # When `configuration.test_mode` is set, a `Zizq::Test::Client` is
80
+ # returned instead — buffering enqueues in memory rather than
81
+ # talking to a real server.
77
82
  def client #: () -> Client
78
83
  @client ||= begin
79
84
  @client_mutex.synchronize do
80
85
  break @client if @client
81
86
 
82
87
  configuration.validate!
83
- @client = Client.new(
84
- url: configuration.url,
85
- format: configuration.format,
86
- ssl_context: configuration.ssl_context,
87
- read_timeout: configuration.read_timeout,
88
- stream_idle_timeout: configuration.stream_idle_timeout
89
- )
88
+ @client = if configuration.test_mode
89
+ Test::Client.new
90
+ else
91
+ Client.new(
92
+ url: configuration.url,
93
+ format: configuration.format,
94
+ ssl_context: configuration.ssl_context,
95
+ read_timeout: configuration.read_timeout,
96
+ stream_idle_timeout: configuration.stream_idle_timeout
97
+ )
98
+ end
90
99
  end
91
100
  end
92
101
  end
@@ -51,6 +51,16 @@ module Zizq
51
51
  # a `Resources::Job` and a chain to continue.
52
52
  attr_reader dequeue_middleware: Middleware::Chain[Resources::Job, void]
53
53
 
54
+ # When truthy, `Zizq.client` lazily resolves to a
55
+ # `Zizq::Test::Client` that buffers enqueues in memory rather than
56
+ # dispatching to a real server. Useful inside test suites — set it
57
+ # once in your test helper and the rest of the app's code uses
58
+ # `Zizq.enqueue` / `Zizq.enqueue_bulk` unchanged. Read operations
59
+ # (`Zizq.query`, `Zizq.queues`, `Client#get_job`, etc.) raise
60
+ # rather than silently returning empty results, so missing test
61
+ # setup is obvious.
62
+ attr_accessor test_mode: bool
63
+
54
64
  def initialize: () -> untyped
55
65
 
56
66
  # TLS settings for connecting to the server over HTTPS.
@@ -0,0 +1,161 @@
1
+ # Generated from lib/zizq/test/client.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ module Test
5
+ # A `Zizq::Client` stand-in for use in test suites.
6
+ #
7
+ # Buffers `enqueue` / `enqueue_bulk` calls in memory and returns
8
+ # synthetic `Resources::Job` instances (with generated ids) so that
9
+ # callers depending on the regular client's return contract don't
10
+ # need to special-case test mode.
11
+ #
12
+ # Read operations (`get_queues`, `list_jobs`, `count_jobs`, …) are
13
+ # explicitly not supported in test mode and raise `NotSupported`.
14
+ # Tests that need those should either run against a real server or
15
+ # stub at a higher level.
16
+ #
17
+ # Activated indirectly via `Zizq.configuration.test_mode = true` —
18
+ # `Zizq.client` then lazily builds a `Test::Client` instead of a
19
+ # real `Client`.
20
+ class Client < Zizq::Client
21
+ # Raised when test-mode code reaches an operation that isn't
22
+ # supported (queries, queue listing, worker streams, etc.).
23
+ class NotSupported < Zizq::Error
24
+ end
25
+
26
+ # Length of a real scru128 id in its base-32 representation.
27
+ # Synthetic test ids are sized to match (`test` prefix + zero
28
+ # padded counter) so they fit anywhere a real id would.
29
+ ID_LENGTH: ::Integer
30
+
31
+ ID_PREFIX: ::String
32
+
33
+ # The canonical Zizq lifecycle states. We mirror these so the
34
+ # `status` on a buffered job reflects what the real server would
35
+ # report. Test mode never retries — `in_flight` only ever
36
+ # transitions to `completed` or `dead`.
37
+ STATUS_SCHEDULED: ::String
38
+
39
+ STATUS_READY: ::String
40
+
41
+ STATUS_IN_FLIGHT: ::String
42
+
43
+ STATUS_COMPLETED: ::String
44
+
45
+ STATUS_DEAD: ::String
46
+
47
+ # Default `filter:` lambda — passes every job. Named so the
48
+ # filter pipeline is always callable without nil-checking.
49
+ PASS_ALL_FILTER: ^(Resources::Job) -> bool
50
+
51
+ # Paired view of a single enqueue: the original `EnqueueRequest`
52
+ # (with full submission metadata — `unique_key`, `unique_while`,
53
+ # retry config, etc.), the synthetic `Resources::Job` returned
54
+ # to callers, and the data hash that backs the job (so we can
55
+ # mutate its status through the lifecycle).
56
+ class Entry < Struct[untyped]
57
+ attr_accessor request(): untyped
58
+
59
+ attr_accessor job(): untyped
60
+
61
+ attr_accessor data(): untyped
62
+
63
+ def self.new: (?request: untyped, ?job: untyped, ?data: untyped) -> instance
64
+ | ({ ?request: untyped, ?job: untyped, ?data: untyped }) -> instance
65
+ end
66
+
67
+ def initialize: () -> untyped
68
+
69
+ # All buffered jobs, in submission order, optionally filtered.
70
+ # See `apply_filters` for the filter kwargs.
71
+ def enqueued_jobs: (**untyped filters) -> untyped
72
+
73
+ # Original `EnqueueRequest`s in submission order. Useful when a
74
+ # test needs metadata that doesn't survive onto `Resources::Job`
75
+ # (`unique_key`, `unique_while`, `delay` before `ready_at`
76
+ # resolution, etc.). Same filter kwargs as `enqueued_jobs`.
77
+ def enqueued_requests: (**untyped filters) -> untyped
78
+
79
+ # Jobs awaiting dispatch — `ready` and `scheduled` entries.
80
+ # `pending_jobs` is the set `drain` would attempt to run on its
81
+ # next call (modulo `ready_at` for `scheduled` entries).
82
+ def pending_jobs: (**untyped filters) -> untyped
83
+
84
+ def in_flight_jobs: (**untyped filters) -> untyped
85
+
86
+ def completed_jobs: (**untyped filters) -> untyped
87
+
88
+ def dead_jobs: (**untyped filters) -> untyped
89
+
90
+ # Reset the buffer. Called between tests via `Zizq::Test.reset!`.
91
+ def clear!: () -> untyped
92
+
93
+ def close: () -> untyped
94
+
95
+ # Dispatch every runnable entry (status `ready`, or `scheduled`
96
+ # with `ready_at` already elapsed) through the configured
97
+ # dequeue middleware chain (same path the real worker uses, so
98
+ # any registered middlewares run in tests too), looping until no
99
+ # more match the filters. Re-enqueues during dispatch fall
100
+ # through the loop naturally — they get drained too unless they
101
+ # fall outside the filter set.
102
+ #
103
+ # The per-iteration snapshot is taken under the mutex and marked
104
+ # `in_flight` atomically. Dispatch happens outside the mutex so
105
+ # handlers can re-enter the client without deadlocking. On
106
+ # success the entry moves to `completed`; on a raised exception
107
+ # it moves to `dead` and the exception re-raises (matching
108
+ # ActiveJob's `perform_enqueued_jobs` + Sidekiq's `drain`).
109
+ def drain: (**untyped filters) -> untyped
110
+
111
+ # @rbs override
112
+ def enqueue: ...
113
+
114
+ # @rbs override
115
+ def enqueue_bulk: ...
116
+
117
+ private
118
+
119
+ def record_unsynchronized: (untyped req) -> untyped
120
+
121
+ def synthetic_id: (untyped counter) -> untyped
122
+
123
+ # Returns entries matching every named filter AND the predicate.
124
+ # All filter kwargs are optional; unset means "don't filter on
125
+ # this axis." Callers must hold `@mutex` — public accessors do
126
+ # so via `synchronize` before calling.
127
+ #
128
+ # * `only_queues:` / `except_queues:` — String, Array of Strings.
129
+ # * `only_types:` / `except_types:` — String, Class, or Array
130
+ # of those. Class names are matched against the wire-format
131
+ # `type` string via `.to_s`.
132
+ # * `filter:` — a lambda receiving a `Resources::Job`, returning
133
+ # truthy to keep. Defaults to `PASS_ALL_FILTER`.
134
+ #
135
+ # `only_*` and `except_*` AND together with the predicate.
136
+ def apply_filters: (untyped entries, ?only_queues: untyped, ?except_queues: untyped, ?only_types: untyped, ?except_types: untyped, ?filter: untyped) -> untyped
137
+
138
+ def filter_by_status: (untyped statuses, **untyped filters) -> untyped
139
+
140
+ # Lock, find runnable entries matching the filters, flip each
141
+ # to in_flight, return the snapshot. Holding the mutex through
142
+ # this is fine because we don't call user code — dispatch
143
+ # happens outside.
144
+ def take_runnable_snapshot: (**untyped filters) -> untyped
145
+
146
+ def runnable?: (untyped entry, untyped now_ms) -> untyped
147
+
148
+ # Accept a Class, String, or Array of those; emit an Array of
149
+ # Strings. Class names match the API's `type` string.
150
+ def normalize_filter: (untyped value) -> untyped
151
+
152
+ # Mark the entry in_flight, dispatch through the full dequeue
153
+ # middleware chain (same path the real worker uses, so any
154
+ # registered middlewares run in tests too), settle to
155
+ # completed or dead. A raised exception re-raises after
156
+ # recording — same observable behaviour as Rails'
157
+ # `perform_enqueued_jobs`.
158
+ def dispatch_entry: (untyped entry) -> untyped
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,118 @@
1
+ # Generated from lib/zizq/test.rb with RBS::Inline
2
+
3
+ module Zizq
4
+ # Test-mode helpers. Activated by setting `c.test_mode = true` in a
5
+ # `Zizq.configure` block; `Zizq.client` then lazily resolves to a
6
+ # `Zizq::Test::Client` that buffers enqueues instead of dispatching.
7
+ #
8
+ # Typical use in a test helper:
9
+ #
10
+ # Zizq.configure do |c|
11
+ # c.test_mode = true
12
+ # end
13
+ #
14
+ # class MyTestCase
15
+ # setup { Zizq::Test.reset! }
16
+ # end
17
+ #
18
+ # In a test:
19
+ #
20
+ # def test_signup_fans_out
21
+ # # Default buffered mode — assert what was enqueued.
22
+ # SignupService.new.run
23
+ # assert_equal 2, Zizq::Test.client.pending_jobs.size
24
+ #
25
+ # # Drain whatever's pending (handler re-enqueues fall through
26
+ # # naturally — drain loops until pending is empty).
27
+ # Zizq::Test.dispatch_enqueued_jobs
28
+ #
29
+ # # Block form: run the work, then drain. Matches ActiveJob's
30
+ # # `perform_enqueued_jobs do ... end`.
31
+ # Zizq::Test.dispatch_enqueued_jobs { SignupService.new.run }
32
+ #
33
+ # # Filter by queue and/or type when only a subset should fire.
34
+ # Zizq::Test.dispatch_enqueued_jobs(queue: "emails")
35
+ # Zizq::Test.dispatch_enqueued_jobs(type: SendEmailJob)
36
+ # end
37
+ module Test
38
+ # Switch Zizq into test mode. After this, `Zizq.client` resolves
39
+ # to a `Zizq::Test::Client` that buffers enqueues in memory.
40
+ # Typically called once in a test helper.
41
+ def self.enable!: () -> untyped
42
+
43
+ # Switch back to the real client. The buffered state is dropped
44
+ # along with the test client (the next `Zizq.client` access
45
+ # builds a fresh `Zizq::Client`).
46
+ def self.disable!: () -> untyped
47
+
48
+ # The active test client. Raises if test mode is not enabled —
49
+ # better to fail loudly than return a stale or wrong client.
50
+ def self.client: () -> untyped
51
+
52
+ # Reset buffered state between tests. Keeps the configured
53
+ # `test_mode` flag.
54
+ def self.reset!: () -> untyped
55
+
56
+ # Dispatch pending jobs through the configured dequeue middleware
57
+ # chain (`Zizq.configuration.dequeue_middleware` — same path the
58
+ # real worker uses, so any registered middlewares run in tests
59
+ # too), looping until no more pending entries match the filters.
60
+ #
61
+ # * No block: drain whatever's pending now.
62
+ # * With block: yield first (test code enqueues), then drain.
63
+ # * `only_queues:` / `except_queues:` — String or Array of Strings.
64
+ # * `only_types:` / `except_types:` — String, Class, or Array of
65
+ # those (Class names match the serialized-format `type` via `.to_s`,
66
+ # so passing an ActiveJob class works directly).
67
+ # * `filter:` — a lambda `->(job)` returning truthy to keep an
68
+ # entry. Defaults to "pass all". Combines with the named
69
+ # filters via AND.
70
+ #
71
+ # Returns the number of jobs dispatched. A block exception
72
+ # propagates without draining; a handler exception during drain
73
+ # transitions that entry to `dead` and re-raises.
74
+ def self.dispatch_enqueued_jobs: (**untyped filters) ?{ (?) -> untyped } -> untyped
75
+
76
+ # Was a job of `job_class` enqueued (optionally with matching
77
+ # args)?
78
+ #
79
+ # Zizq::Test.enqueued?(SendEmailJob) # any args
80
+ # Zizq::Test.enqueued?(SendEmailJob, 42, template: "welcome") # exact args
81
+ #
82
+ # With no args, matches by class/type only. With args/kwargs,
83
+ # uses the class's own `zizq_serialize` to compute the expected
84
+ # payload — so it works for both `Zizq::Job` and AJ classes
85
+ # (AJ's volatile fields like `job_id` are ignored, only the
86
+ # `arguments` subset is compared).
87
+ #
88
+ # For anything fuzzier (matchers, subset matching, custom
89
+ # predicates), drop down to
90
+ # `client.enqueued_jobs(only_types: ..., filter: ->(job) { ... })`.
91
+ def self.enqueued?: (untyped job_class, *untyped args, **untyped kwargs) -> untyped
92
+
93
+ # How many times was a job of `job_class` enqueued (optionally
94
+ # with matching args)? Same argument semantics as `#enqueued?`.
95
+ def self.enqueued_count: (untyped job_class, *untyped args, **untyped kwargs) -> untyped
96
+
97
+ # Was a raw job (queue + type + payload) enqueued? Each kwarg is
98
+ # optional; unspecified means "don't filter on this axis."
99
+ #
100
+ # Zizq::Test.enqueued_raw?(type: "send_email")
101
+ # Zizq::Test.enqueued_raw?(type: "send_email", payload: { user_id: 42 })
102
+ # Zizq::Test.enqueued_raw?(queue: "emails", type: "send_email")
103
+ def self.enqueued_raw?: (?queue: untyped, ?type: untyped, ?payload: untyped) -> untyped
104
+
105
+ # How many raw jobs match (queue + type + payload)? Same
106
+ # argument semantics as `#enqueued_raw?`.
107
+ def self.enqueued_raw_count: (?queue: untyped, ?type: untyped, ?payload: untyped) -> untyped
108
+
109
+ # Heuristic payload comparison that handles both `Zizq::Job`'s
110
+ # serialized format (`{"args" => [...], "kwargs" => {...}}`) and
111
+ # ActiveJob's (`{"job_class" => ..., "arguments" => [...],
112
+ # "job_id" => ..., "enqueued_at" => ..., ...}`). The AJ shape
113
+ # always has an `"arguments"` key while `Zizq::Job`'s doesn't, so
114
+ # we use that to pick whether to compare the full hash or only the
115
+ # `arguments` subset (dropping AJ's volatile per-enqueue fields).
116
+ def self.payloads_equivalent?: (untyped expected, untyped actual) -> untyped
117
+ end
118
+ end
@@ -33,6 +33,10 @@ module Zizq
33
33
  #
34
34
  # The client is memoized so that persistent HTTP connections are reused
35
35
  # across calls, reducing TCP connection overhead.
36
+ #
37
+ # When `configuration.test_mode` is set, a `Zizq::Test::Client` is
38
+ # returned instead — buffering enqueues in memory rather than
39
+ # talking to a real server.
36
40
  def self.client: () -> untyped
37
41
 
38
42
  # Resets all global state: configuration and shared client.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zizq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Corbyn <chris@zizq.io>
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-25 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async-http
@@ -100,6 +100,8 @@ files:
100
100
  - lib/zizq/resources/job_template.rb
101
101
  - lib/zizq/resources/page.rb
102
102
  - lib/zizq/resources/resource.rb
103
+ - lib/zizq/test.rb
104
+ - lib/zizq/test/client.rb
103
105
  - lib/zizq/tls_configuration.rb
104
106
  - lib/zizq/version.rb
105
107
  - lib/zizq/worker.rb
@@ -133,6 +135,8 @@ files:
133
135
  - sig/generated/zizq/resources/job_template.rbs
134
136
  - sig/generated/zizq/resources/page.rbs
135
137
  - sig/generated/zizq/resources/resource.rbs
138
+ - sig/generated/zizq/test.rbs
139
+ - sig/generated/zizq/test/client.rbs
136
140
  - sig/generated/zizq/tls_configuration.rbs
137
141
  - sig/generated/zizq/version.rbs
138
142
  - sig/generated/zizq/worker.rbs