brainzlab 0.1.10 → 0.1.12

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.
@@ -0,0 +1,650 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Testing
5
+ # Test helpers for RSpec and Minitest
6
+ #
7
+ # Include this module in your test helper to gain access to
8
+ # BrainzLab testing utilities.
9
+ #
10
+ # @example RSpec configuration
11
+ # RSpec.configure do |config|
12
+ # config.include BrainzLab::Testing::Helpers
13
+ #
14
+ # config.before(:each) do
15
+ # stub_brainzlab!
16
+ # end
17
+ #
18
+ # config.after(:each) do
19
+ # clear_brainzlab_events!
20
+ # end
21
+ # end
22
+ #
23
+ # @example Minitest configuration
24
+ # class ActiveSupport::TestCase
25
+ # include BrainzLab::Testing::Helpers
26
+ #
27
+ # setup do
28
+ # stub_brainzlab!
29
+ # end
30
+ #
31
+ # teardown do
32
+ # clear_brainzlab_events!
33
+ # end
34
+ # end
35
+ #
36
+ module Helpers
37
+ # Stub all BrainzLab SDK calls to prevent real API requests
38
+ # and enable event capturing for assertions.
39
+ #
40
+ # @example
41
+ # it 'tracks user signup' do
42
+ # stub_brainzlab!
43
+ # UserService.new.register(email: 'test@example.com')
44
+ # expect(brainzlab_events).to include(hash_including(name: 'user.signup'))
45
+ # end
46
+ #
47
+ def stub_brainzlab!
48
+ BrainzLab::Testing.enable!
49
+ end
50
+
51
+ # Restore original BrainzLab SDK behavior
52
+ #
53
+ # @note This is typically called automatically if you're using
54
+ # clear_brainzlab_events! in your teardown, which also restores state.
55
+ def unstub_brainzlab!
56
+ BrainzLab::Testing.disable!
57
+ end
58
+
59
+ # Clear all captured events, logs, errors, and metrics
60
+ #
61
+ # Call this in your test teardown or between test scenarios
62
+ # to ensure a clean slate.
63
+ #
64
+ # @example
65
+ # after(:each) do
66
+ # clear_brainzlab_events!
67
+ # end
68
+ #
69
+ def clear_brainzlab_events!
70
+ BrainzLab::Testing.event_store.clear!
71
+ end
72
+
73
+ # Access all captured Flux events
74
+ #
75
+ # @return [Array<Hash>] Array of captured events
76
+ #
77
+ # @example
78
+ # brainzlab_events
79
+ # # => [{ name: 'user.signup', properties: { user_id: 1 }, timestamp: ... }]
80
+ #
81
+ def brainzlab_events
82
+ BrainzLab::Testing.event_store.events
83
+ end
84
+
85
+ # Access events filtered by name
86
+ #
87
+ # @param name [String, Symbol] Event name to filter by
88
+ # @return [Array<Hash>] Matching events
89
+ #
90
+ # @example
91
+ # brainzlab_events_named('user.signup')
92
+ # # => [{ name: 'user.signup', properties: { user_id: 1 }, timestamp: ... }]
93
+ #
94
+ def brainzlab_events_named(name)
95
+ BrainzLab::Testing.event_store.events_named(name)
96
+ end
97
+
98
+ # Access all captured Flux metrics
99
+ #
100
+ # @return [Array<Hash>] Array of captured metrics
101
+ #
102
+ # @example
103
+ # brainzlab_metrics
104
+ # # => [{ type: :increment, name: 'orders.count', value: 1, tags: {} }]
105
+ #
106
+ def brainzlab_metrics
107
+ BrainzLab::Testing.event_store.metrics
108
+ end
109
+
110
+ # Access all captured Recall logs
111
+ #
112
+ # @return [Array<Hash>] Array of captured log entries
113
+ #
114
+ # @example
115
+ # brainzlab_logs
116
+ # # => [{ level: :info, message: 'User created', data: { user_id: 1 } }]
117
+ #
118
+ def brainzlab_logs
119
+ BrainzLab::Testing.event_store.logs
120
+ end
121
+
122
+ # Access logs filtered by level
123
+ #
124
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
125
+ # @return [Array<Hash>] Matching log entries
126
+ #
127
+ def brainzlab_logs_at_level(level)
128
+ BrainzLab::Testing.event_store.logs_at_level(level)
129
+ end
130
+
131
+ # Access all captured Reflex errors
132
+ #
133
+ # @return [Array<Hash>] Array of captured errors
134
+ #
135
+ # @example
136
+ # brainzlab_errors
137
+ # # => [{ exception: #<RuntimeError>, error_class: 'RuntimeError', message: 'Oops' }]
138
+ #
139
+ def brainzlab_errors
140
+ BrainzLab::Testing.event_store.errors
141
+ end
142
+
143
+ # Access all captured Pulse traces
144
+ #
145
+ # @return [Array<Hash>] Array of captured traces
146
+ #
147
+ def brainzlab_traces
148
+ BrainzLab::Testing.event_store.traces
149
+ end
150
+
151
+ # Access all captured Signal alerts
152
+ #
153
+ # @return [Array<Hash>] Array of captured alerts
154
+ #
155
+ def brainzlab_alerts
156
+ BrainzLab::Testing.event_store.alerts
157
+ end
158
+
159
+ # Access all captured Signal notifications
160
+ #
161
+ # @return [Array<Hash>] Array of captured notifications
162
+ #
163
+ def brainzlab_notifications
164
+ BrainzLab::Testing.event_store.notifications
165
+ end
166
+
167
+ # Check if a specific event was tracked
168
+ #
169
+ # @param name [String, Symbol] Event name
170
+ # @param properties [Hash, nil] Optional properties to match
171
+ # @return [Boolean]
172
+ #
173
+ # @example
174
+ # brainzlab_event_tracked?('user.signup')
175
+ # brainzlab_event_tracked?('user.signup', user_id: 1)
176
+ #
177
+ def brainzlab_event_tracked?(name, properties = nil)
178
+ BrainzLab::Testing.event_store.event_tracked?(name, properties)
179
+ end
180
+
181
+ # Check if a specific metric was recorded
182
+ #
183
+ # @param type [Symbol] Metric type (:gauge, :increment, :distribution, etc.)
184
+ # @param name [String, Symbol] Metric name
185
+ # @param value [Numeric, nil] Optional value to match
186
+ # @param tags [Hash, nil] Optional tags to match
187
+ # @return [Boolean]
188
+ #
189
+ def brainzlab_metric_recorded?(type, name, value: nil, tags: nil)
190
+ BrainzLab::Testing.event_store.metric_recorded?(type, name, value: value, tags: tags)
191
+ end
192
+
193
+ # Check if a specific log message was recorded
194
+ #
195
+ # @param level [Symbol] Log level
196
+ # @param message [String, Regexp, nil] Optional message to match
197
+ # @param data [Hash, nil] Optional data to match
198
+ # @return [Boolean]
199
+ #
200
+ def brainzlab_logged?(level, message = nil, data = nil)
201
+ BrainzLab::Testing.event_store.logged?(level, message, data)
202
+ end
203
+
204
+ # Check if a specific error was captured
205
+ #
206
+ # @param error_class [Class, String, nil] Error class to match
207
+ # @param message [String, Regexp, nil] Optional message to match
208
+ # @param context [Hash, nil] Optional context to match
209
+ # @return [Boolean]
210
+ #
211
+ def brainzlab_error_captured?(error_class = nil, message: nil, context: nil)
212
+ BrainzLab::Testing.event_store.error_captured?(error_class, message: message, context: context)
213
+ end
214
+
215
+ # Check if a specific trace was recorded
216
+ #
217
+ # @param name [String, Symbol] Trace name
218
+ # @param opts [Hash, nil] Optional options to match
219
+ # @return [Boolean]
220
+ #
221
+ def brainzlab_trace_recorded?(name, opts = nil)
222
+ BrainzLab::Testing.event_store.trace_recorded?(name, opts)
223
+ end
224
+
225
+ # Check if a specific alert was sent
226
+ #
227
+ # @param name [String, Symbol] Alert name
228
+ # @param message [String, nil] Optional message to match
229
+ # @param severity [Symbol, nil] Optional severity to match
230
+ # @return [Boolean]
231
+ #
232
+ def brainzlab_alert_sent?(name, message: nil, severity: nil)
233
+ BrainzLab::Testing.event_store.alert_sent?(name, message: message, severity: severity)
234
+ end
235
+
236
+ # Get the last captured event
237
+ #
238
+ # @return [Hash, nil] The last event or nil
239
+ #
240
+ def last_brainzlab_event
241
+ BrainzLab::Testing.event_store.last_event
242
+ end
243
+
244
+ # Get the last captured error
245
+ #
246
+ # @return [Hash, nil] The last error or nil
247
+ #
248
+ def last_brainzlab_error
249
+ BrainzLab::Testing.event_store.last_error
250
+ end
251
+
252
+ # Create an event expectation builder for fluent assertions
253
+ #
254
+ # @param name [String, Symbol] Event name to expect
255
+ # @return [EventExpectation] Expectation builder
256
+ #
257
+ # @example RSpec usage
258
+ # expect_brainzlab_event('user.signup').with(user_id: 1)
259
+ # expect_brainzlab_event('order.completed').with(order_id: 42, total: 99.99)
260
+ #
261
+ def expect_brainzlab_event(name)
262
+ EventExpectation.new(name, BrainzLab::Testing.event_store)
263
+ end
264
+
265
+ # Create an error expectation builder for fluent assertions
266
+ #
267
+ # @param error_class [Class, String] Error class to expect
268
+ # @return [ErrorExpectation] Expectation builder
269
+ #
270
+ # @example RSpec usage
271
+ # expect_brainzlab_error(RuntimeError).with_message(/something went wrong/i)
272
+ #
273
+ def expect_brainzlab_error(error_class)
274
+ ErrorExpectation.new(error_class, BrainzLab::Testing.event_store)
275
+ end
276
+
277
+ # Create a log expectation builder for fluent assertions
278
+ #
279
+ # @param level [Symbol] Log level to expect
280
+ # @return [LogExpectation] Expectation builder
281
+ #
282
+ # @example RSpec usage
283
+ # expect_brainzlab_log(:info).with_message('User created')
284
+ #
285
+ def expect_brainzlab_log(level)
286
+ LogExpectation.new(level, BrainzLab::Testing.event_store)
287
+ end
288
+
289
+ # Create a metric expectation builder for fluent assertions
290
+ #
291
+ # @param type [Symbol] Metric type (:gauge, :increment, :distribution, etc.)
292
+ # @param name [String, Symbol] Metric name
293
+ # @return [MetricExpectation] Expectation builder
294
+ #
295
+ # @example RSpec usage
296
+ # expect_brainzlab_metric(:increment, 'orders.count').with_value(1)
297
+ #
298
+ def expect_brainzlab_metric(type, name)
299
+ MetricExpectation.new(type, name, BrainzLab::Testing.event_store)
300
+ end
301
+
302
+ # Create a trace expectation builder for fluent assertions
303
+ #
304
+ # @param name [String, Symbol] Trace name to expect
305
+ # @return [TraceExpectation] Expectation builder
306
+ #
307
+ # @example RSpec usage
308
+ # expect_brainzlab_trace('db.query')
309
+ #
310
+ def expect_brainzlab_trace(name)
311
+ TraceExpectation.new(name, BrainzLab::Testing.event_store)
312
+ end
313
+
314
+ # Create an alert expectation builder for fluent assertions
315
+ #
316
+ # @param name [String, Symbol] Alert name to expect
317
+ # @return [AlertExpectation] Expectation builder
318
+ #
319
+ # @example RSpec usage
320
+ # expect_brainzlab_alert('high_error_rate').with_severity(:critical)
321
+ #
322
+ def expect_brainzlab_alert(name)
323
+ AlertExpectation.new(name, BrainzLab::Testing.event_store)
324
+ end
325
+ end
326
+
327
+ # Fluent expectation builder for events
328
+ class EventExpectation
329
+ def initialize(name, store)
330
+ @name = name.to_s
331
+ @store = store
332
+ @expected_properties = {}
333
+ end
334
+
335
+ # Specify expected properties
336
+ #
337
+ # @param properties [Hash] Properties to match
338
+ # @return [self]
339
+ #
340
+ def with(properties = {})
341
+ @expected_properties = properties
342
+ self
343
+ end
344
+
345
+ # Check if the expectation is satisfied
346
+ #
347
+ # @return [Boolean]
348
+ #
349
+ def satisfied?
350
+ @store.event_tracked?(@name, @expected_properties.empty? ? nil : @expected_properties)
351
+ end
352
+
353
+ # Alias for satisfied? (for RSpec matchers)
354
+ alias matches? satisfied?
355
+
356
+ # Get matching events
357
+ #
358
+ # @return [Array<Hash>]
359
+ #
360
+ def matching_events
361
+ events = @store.events_named(@name)
362
+ return events if @expected_properties.empty?
363
+
364
+ events.select { |e| properties_match?(e[:properties], @expected_properties) }
365
+ end
366
+
367
+ # Failure message for RSpec
368
+ def failure_message
369
+ if @expected_properties.empty?
370
+ "expected event '#{@name}' to be tracked, but it wasn't"
371
+ else
372
+ "expected event '#{@name}' with properties #{@expected_properties.inspect} to be tracked, " \
373
+ "but got: #{@store.events_named(@name).map { |e| e[:properties] }.inspect}"
374
+ end
375
+ end
376
+
377
+ # Negative failure message for RSpec
378
+ def failure_message_when_negated
379
+ if @expected_properties.empty?
380
+ "expected event '#{@name}' not to be tracked, but it was"
381
+ else
382
+ "expected event '#{@name}' with properties #{@expected_properties.inspect} not to be tracked, but it was"
383
+ end
384
+ end
385
+
386
+ private
387
+
388
+ def properties_match?(actual, expected)
389
+ expected.all? do |key, value|
390
+ actual_value = actual[key] || actual[key.to_s] || actual[key.to_sym]
391
+ case value
392
+ when Regexp
393
+ actual_value.to_s.match?(value)
394
+ else
395
+ actual_value == value
396
+ end
397
+ end
398
+ end
399
+ end
400
+
401
+ # Fluent expectation builder for errors
402
+ class ErrorExpectation
403
+ def initialize(error_class, store)
404
+ @error_class = error_class
405
+ @store = store
406
+ @expected_message = nil
407
+ @expected_context = nil
408
+ end
409
+
410
+ # Specify expected message
411
+ #
412
+ # @param message [String, Regexp] Message to match
413
+ # @return [self]
414
+ #
415
+ def with_message(message)
416
+ @expected_message = message
417
+ self
418
+ end
419
+
420
+ # Specify expected context
421
+ #
422
+ # @param context [Hash] Context to match
423
+ # @return [self]
424
+ #
425
+ def with_context(context)
426
+ @expected_context = context
427
+ self
428
+ end
429
+
430
+ # Check if the expectation is satisfied
431
+ #
432
+ # @return [Boolean]
433
+ #
434
+ def satisfied?
435
+ @store.error_captured?(@error_class, message: @expected_message, context: @expected_context)
436
+ end
437
+
438
+ alias matches? satisfied?
439
+
440
+ def failure_message
441
+ parts = ["expected error #{@error_class} to be captured"]
442
+ parts << "with message matching #{@expected_message.inspect}" if @expected_message
443
+ parts << "with context #{@expected_context.inspect}" if @expected_context
444
+ parts << ", but got: #{@store.errors.map { |e| { class: e[:error_class], message: e[:message] } }.inspect}"
445
+ parts.join
446
+ end
447
+
448
+ def failure_message_when_negated
449
+ "expected error #{@error_class} not to be captured, but it was"
450
+ end
451
+ end
452
+
453
+ # Fluent expectation builder for logs
454
+ class LogExpectation
455
+ def initialize(level, store)
456
+ @level = level.to_sym
457
+ @store = store
458
+ @expected_message = nil
459
+ @expected_data = nil
460
+ end
461
+
462
+ # Specify expected message
463
+ #
464
+ # @param message [String, Regexp] Message to match
465
+ # @return [self]
466
+ #
467
+ def with_message(message)
468
+ @expected_message = message
469
+ self
470
+ end
471
+
472
+ # Specify expected data
473
+ #
474
+ # @param data [Hash] Data to match
475
+ # @return [self]
476
+ #
477
+ def with_data(data)
478
+ @expected_data = data
479
+ self
480
+ end
481
+
482
+ # Check if the expectation is satisfied
483
+ #
484
+ # @return [Boolean]
485
+ #
486
+ def satisfied?
487
+ @store.logged?(@level, @expected_message, @expected_data)
488
+ end
489
+
490
+ alias matches? satisfied?
491
+
492
+ def failure_message
493
+ parts = ["expected log at level :#{@level}"]
494
+ parts << "with message matching #{@expected_message.inspect}" if @expected_message
495
+ parts << "with data #{@expected_data.inspect}" if @expected_data
496
+ parts << ", but got: #{@store.logs_at_level(@level).map { |l| { message: l[:message], data: l[:data] } }.inspect}"
497
+ parts.join
498
+ end
499
+
500
+ def failure_message_when_negated
501
+ "expected no log at level :#{@level} to be recorded, but it was"
502
+ end
503
+ end
504
+
505
+ # Fluent expectation builder for metrics
506
+ class MetricExpectation
507
+ def initialize(type, name, store)
508
+ @type = type.to_sym
509
+ @name = name.to_s
510
+ @store = store
511
+ @expected_value = nil
512
+ @expected_tags = nil
513
+ end
514
+
515
+ # Specify expected value
516
+ #
517
+ # @param value [Numeric] Value to match
518
+ # @return [self]
519
+ #
520
+ def with_value(value)
521
+ @expected_value = value
522
+ self
523
+ end
524
+
525
+ # Specify expected tags
526
+ #
527
+ # @param tags [Hash] Tags to match
528
+ # @return [self]
529
+ #
530
+ def with_tags(tags)
531
+ @expected_tags = tags
532
+ self
533
+ end
534
+
535
+ # Check if the expectation is satisfied
536
+ #
537
+ # @return [Boolean]
538
+ #
539
+ def satisfied?
540
+ @store.metric_recorded?(@type, @name, value: @expected_value, tags: @expected_tags)
541
+ end
542
+
543
+ alias matches? satisfied?
544
+
545
+ def failure_message
546
+ parts = ["expected metric #{@type}('#{@name}')"]
547
+ parts << "with value #{@expected_value.inspect}" if @expected_value
548
+ parts << "with tags #{@expected_tags.inspect}" if @expected_tags
549
+ parts << ", but got: #{@store.metrics_named(@name).inspect}"
550
+ parts.join
551
+ end
552
+
553
+ def failure_message_when_negated
554
+ "expected metric #{@type}('#{@name}') not to be recorded, but it was"
555
+ end
556
+ end
557
+
558
+ # Fluent expectation builder for traces
559
+ class TraceExpectation
560
+ def initialize(name, store)
561
+ @name = name.to_s
562
+ @store = store
563
+ @expected_opts = nil
564
+ end
565
+
566
+ # Specify expected options
567
+ #
568
+ # @param opts [Hash] Options to match
569
+ # @return [self]
570
+ #
571
+ def with(opts)
572
+ @expected_opts = opts
573
+ self
574
+ end
575
+
576
+ # Check if the expectation is satisfied
577
+ #
578
+ # @return [Boolean]
579
+ #
580
+ def satisfied?
581
+ @store.trace_recorded?(@name, @expected_opts)
582
+ end
583
+
584
+ alias matches? satisfied?
585
+
586
+ def failure_message
587
+ parts = ["expected trace '#{@name}'"]
588
+ parts << "with options #{@expected_opts.inspect}" if @expected_opts
589
+ parts << " to be recorded, but it wasn't"
590
+ parts.join
591
+ end
592
+
593
+ def failure_message_when_negated
594
+ "expected trace '#{@name}' not to be recorded, but it was"
595
+ end
596
+ end
597
+
598
+ # Fluent expectation builder for alerts
599
+ class AlertExpectation
600
+ def initialize(name, store)
601
+ @name = name.to_s
602
+ @store = store
603
+ @expected_message = nil
604
+ @expected_severity = nil
605
+ end
606
+
607
+ # Specify expected message
608
+ #
609
+ # @param message [String] Message to match
610
+ # @return [self]
611
+ #
612
+ def with_message(message)
613
+ @expected_message = message
614
+ self
615
+ end
616
+
617
+ # Specify expected severity
618
+ #
619
+ # @param severity [Symbol] Severity to match
620
+ # @return [self]
621
+ #
622
+ def with_severity(severity)
623
+ @expected_severity = severity
624
+ self
625
+ end
626
+
627
+ # Check if the expectation is satisfied
628
+ #
629
+ # @return [Boolean]
630
+ #
631
+ def satisfied?
632
+ @store.alert_sent?(@name, message: @expected_message, severity: @expected_severity)
633
+ end
634
+
635
+ alias matches? satisfied?
636
+
637
+ def failure_message
638
+ parts = ["expected alert '#{@name}'"]
639
+ parts << "with message '#{@expected_message}'" if @expected_message
640
+ parts << "with severity :#{@expected_severity}" if @expected_severity
641
+ parts << " to be sent, but it wasn't"
642
+ parts.join
643
+ end
644
+
645
+ def failure_message_when_negated
646
+ "expected alert '#{@name}' not to be sent, but it was"
647
+ end
648
+ end
649
+ end
650
+ end