source_monitor 0.3.0 → 0.3.2

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/sm-architecture/SKILL.md +233 -0
  3. data/.claude/skills/sm-architecture/reference/extraction-patterns.md +192 -0
  4. data/.claude/skills/sm-architecture/reference/module-map.md +194 -0
  5. data/.claude/skills/sm-configuration-setting/SKILL.md +264 -0
  6. data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +248 -0
  7. data/.claude/skills/sm-configuration-setting/reference/settings-pattern.md +297 -0
  8. data/.claude/skills/sm-configure/SKILL.md +153 -0
  9. data/.claude/skills/sm-configure/reference/configuration-reference.md +321 -0
  10. data/.claude/skills/sm-dashboard-widget/SKILL.md +344 -0
  11. data/.claude/skills/sm-dashboard-widget/reference/dashboard-patterns.md +304 -0
  12. data/.claude/skills/sm-domain-model/SKILL.md +188 -0
  13. data/.claude/skills/sm-domain-model/reference/model-graph.md +114 -0
  14. data/.claude/skills/sm-domain-model/reference/table-structure.md +348 -0
  15. data/.claude/skills/sm-engine-migration/SKILL.md +395 -0
  16. data/.claude/skills/sm-engine-migration/reference/migration-conventions.md +255 -0
  17. data/.claude/skills/sm-engine-test/SKILL.md +302 -0
  18. data/.claude/skills/sm-engine-test/reference/test-helpers.md +259 -0
  19. data/.claude/skills/sm-engine-test/reference/test-patterns.md +411 -0
  20. data/.claude/skills/sm-event-handler/SKILL.md +265 -0
  21. data/.claude/skills/sm-event-handler/reference/events-api.md +229 -0
  22. data/.claude/skills/sm-health-rule/SKILL.md +327 -0
  23. data/.claude/skills/sm-health-rule/reference/health-system.md +269 -0
  24. data/.claude/skills/sm-host-setup/SKILL.md +223 -0
  25. data/.claude/skills/sm-host-setup/reference/initializer-template.md +195 -0
  26. data/.claude/skills/sm-host-setup/reference/setup-checklist.md +134 -0
  27. data/.claude/skills/sm-job/SKILL.md +263 -0
  28. data/.claude/skills/sm-job/reference/job-conventions.md +245 -0
  29. data/.claude/skills/sm-model-extension/SKILL.md +287 -0
  30. data/.claude/skills/sm-model-extension/reference/extension-api.md +317 -0
  31. data/.claude/skills/sm-pipeline-stage/SKILL.md +254 -0
  32. data/.claude/skills/sm-pipeline-stage/reference/completion-handlers.md +152 -0
  33. data/.claude/skills/sm-pipeline-stage/reference/entry-processing.md +191 -0
  34. data/.claude/skills/sm-pipeline-stage/reference/feed-fetcher-architecture.md +198 -0
  35. data/.claude/skills/sm-scraper-adapter/SKILL.md +284 -0
  36. data/.claude/skills/sm-scraper-adapter/reference/adapter-contract.md +167 -0
  37. data/.claude/skills/sm-scraper-adapter/reference/example-adapter.md +274 -0
  38. data/.vbw-planning/.notification-log.jsonl +102 -0
  39. data/.vbw-planning/.session-log.jsonl +505 -0
  40. data/AGENTS.md +20 -57
  41. data/CHANGELOG.md +19 -0
  42. data/CLAUDE.md +44 -1
  43. data/CONTRIBUTING.md +5 -5
  44. data/Gemfile.lock +20 -21
  45. data/README.md +18 -5
  46. data/VERSION +1 -0
  47. data/docs/deployment.md +1 -1
  48. data/docs/setup.md +4 -4
  49. data/lib/source_monitor/setup/skills_installer.rb +94 -0
  50. data/lib/source_monitor/setup/workflow.rb +17 -2
  51. data/lib/source_monitor/version.rb +1 -1
  52. data/lib/tasks/source_monitor_setup.rake +58 -0
  53. data/source_monitor.gemspec +1 -0
  54. metadata +39 -1
@@ -0,0 +1,411 @@
1
+ # Test Patterns Reference
2
+
3
+ ## VCR Cassette Patterns
4
+
5
+ ### Configuration
6
+
7
+ ```ruby
8
+ # test/test_helper.rb
9
+ VCR.configure do |config|
10
+ config.cassette_library_dir = File.expand_path("vcr_cassettes", __dir__)
11
+ config.hook_into :webmock
12
+ config.ignore_localhost = true
13
+ end
14
+ ```
15
+
16
+ ### Recording a Cassette
17
+
18
+ ```ruby
19
+ VCR.use_cassette("source_monitor/fetching/rss_success") do
20
+ result = FeedFetcher.new(source: source, jitter: ->(_) { 0 }).call
21
+ end
22
+ ```
23
+
24
+ ### Cassette Naming Convention
25
+
26
+ ```
27
+ test/vcr_cassettes/
28
+ source_monitor/
29
+ fetching/
30
+ rss_success.yml
31
+ atom_success.yml
32
+ json_success.yml
33
+ scraping/
34
+ readability_success.yml
35
+ ```
36
+
37
+ Pattern: `source_monitor/<module>/<format_or_scenario>`
38
+
39
+ ### Multiple Formats
40
+
41
+ ```ruby
42
+ feeds = {
43
+ rss: { url: "https://example.com/rss", parser: Feedjira::Parser::RSS },
44
+ atom: { url: "https://example.com/atom", parser: Feedjira::Parser::Atom },
45
+ json: { url: "https://example.com/json", parser: Feedjira::Parser::JSONFeed }
46
+ }
47
+
48
+ feeds.each do |format, data|
49
+ source = create_source!(name: "#{format} feed", feed_url: data[:url])
50
+
51
+ VCR.use_cassette("source_monitor/fetching/#{format}_success") do
52
+ result = FeedFetcher.new(source: source, jitter: ->(_) { 0 }).call
53
+ assert_equal :fetched, result.status
54
+ assert_kind_of data[:parser], result.feed
55
+ end
56
+ end
57
+ ```
58
+
59
+ ---
60
+
61
+ ## WebMock Stub Patterns
62
+
63
+ ### Basic Stubs
64
+
65
+ ```ruby
66
+ # Successful response
67
+ stub_request(:get, url)
68
+ .to_return(status: 200, body: body, headers: { "Content-Type" => "application/rss+xml" })
69
+
70
+ # 304 Not Modified
71
+ stub_request(:get, url)
72
+ .to_return(status: 304, headers: { "ETag" => '"abc"' })
73
+
74
+ # 404 Not Found
75
+ stub_request(:get, url)
76
+ .to_return(status: 404, body: "Not Found", headers: { "Content-Type" => "text/plain" })
77
+ ```
78
+
79
+ ### Conditional Headers
80
+
81
+ ```ruby
82
+ # Match specific request headers
83
+ stub_request(:get, url)
84
+ .with(headers: {
85
+ "If-None-Match" => '"etag123"',
86
+ "If-Modified-Since" => last_mod.httpdate
87
+ })
88
+ .to_return(status: 304, headers: { "ETag" => '"etag123"' })
89
+
90
+ # Custom headers on source
91
+ stub_request(:get, url)
92
+ .with(headers: { "X-Api-Key" => "secret123" })
93
+ .to_return(status: 200, body: body, headers: { "Content-Type" => "application/rss+xml" })
94
+ ```
95
+
96
+ ### Error Stubs
97
+
98
+ ```ruby
99
+ # Timeout
100
+ stub_request(:get, url).to_raise(Faraday::TimeoutError.new("execution expired"))
101
+
102
+ # Connection failure
103
+ stub_request(:get, url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
104
+
105
+ # SSL error
106
+ stub_request(:get, url).to_raise(Faraday::SSLError.new("SSL certificate problem"))
107
+
108
+ # Generic Faraday error
109
+ stub_request(:get, url).to_raise(Faraday::Error.new("something unexpected"))
110
+ ```
111
+
112
+ ### Sequential Responses
113
+
114
+ ```ruby
115
+ # First call succeeds, second returns 304
116
+ stub_request(:get, url)
117
+ .to_return(status: 200, body: body, headers: {
118
+ "Content-Type" => "application/rss+xml",
119
+ "ETag" => '"abcd1234"'
120
+ })
121
+
122
+ # Re-stub for second call with conditional headers
123
+ stub_request(:get, url)
124
+ .with(headers: { "If-None-Match" => '"abcd1234"' })
125
+ .to_return(status: 304, headers: { "ETag" => '"abcd1234"' })
126
+ ```
127
+
128
+ ### Using File Fixtures
129
+
130
+ ```ruby
131
+ body = File.read(file_fixture("feeds/rss_sample.xml"))
132
+
133
+ stub_request(:get, url)
134
+ .to_return(status: 200, body: body, headers: { "Content-Type" => "application/rss+xml" })
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Test Isolation Patterns
140
+
141
+ ### Problem: Parallel Test Contamination
142
+
143
+ Tests run in parallel with fork-based workers. Each worker shares the database. If Test A creates a Source and Test B counts all Sources, Test B may see Test A's data.
144
+
145
+ ### Solution: Scope All Queries
146
+
147
+ ```ruby
148
+ # CORRECT: scope to specific records
149
+ assert_equal 3, SourceMonitor::Item.where(source: source).count
150
+ assert_includes Source.active, my_source
151
+ assert_not_includes Source.active, inactive_source
152
+
153
+ # INCORRECT: global counts
154
+ assert_equal 3, SourceMonitor::Item.count
155
+ assert_equal 1, Source.active.count
156
+ ```
157
+
158
+ ### Solution: Unique Feed URLs
159
+
160
+ `create_source!` auto-generates unique URLs:
161
+
162
+ ```ruby
163
+ # Default: unique hex suffix
164
+ source = create_source! # feed_url: "https://example.com/feed-a1b2c3d4.xml"
165
+
166
+ # When specifying URL, ensure uniqueness
167
+ source = create_source!(feed_url: "https://example.com/my-test-#{SecureRandom.hex(4)}.xml")
168
+ ```
169
+
170
+ ### Solution: Clean Tables
171
+
172
+ For tests that must assert global state:
173
+
174
+ ```ruby
175
+ class GlobalStateTest < ActiveSupport::TestCase
176
+ setup do
177
+ clean_source_monitor_tables!
178
+ end
179
+
180
+ test "no sources exist initially" do
181
+ assert_equal 0, SourceMonitor::Source.count
182
+ end
183
+ end
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Controller Test Patterns
189
+
190
+ ### Basic CRUD
191
+
192
+ ```ruby
193
+ module SourceMonitor
194
+ class SourcesControllerTest < ActionDispatch::IntegrationTest
195
+ test "index returns success" do
196
+ get "/source_monitor/sources"
197
+ assert_response :success
198
+ end
199
+
200
+ test "create saves source" do
201
+ assert_difference -> { Source.count }, 1 do
202
+ post "/source_monitor/sources", params: {
203
+ source: {
204
+ name: "New Source",
205
+ feed_url: "https://example.com/feed.xml",
206
+ fetch_interval_minutes: 60
207
+ }
208
+ }
209
+ end
210
+ end
211
+ end
212
+ end
213
+ ```
214
+
215
+ ### Turbo Stream Responses
216
+
217
+ ```ruby
218
+ test "destroy responds with turbo stream" do
219
+ source = create_source!
220
+
221
+ delete source_monitor.source_path(source), as: :turbo_stream
222
+
223
+ assert_response :success
224
+ assert_equal "text/vnd.turbo-stream.html", response.media_type
225
+ assert_includes response.body, %(<turbo-stream action="remove")
226
+ end
227
+ ```
228
+
229
+ ### Input Sanitization
230
+
231
+ ```ruby
232
+ test "sanitizes XSS in params" do
233
+ post "/source_monitor/sources", params: {
234
+ source: {
235
+ name: "<script>alert(1)</script>Example",
236
+ feed_url: "https://example.com/feed.xml"
237
+ }
238
+ }
239
+
240
+ source = Source.order(:created_at).last
241
+ refute_includes source.name, "<"
242
+ end
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Model Test Patterns
248
+
249
+ ### Validation Testing
250
+
251
+ ```ruby
252
+ test "rejects invalid feed URLs" do
253
+ source = Source.new(name: "Bad", feed_url: "ftp://example.com/feed.xml")
254
+ assert_not source.valid?
255
+ assert_includes source.errors[:feed_url], "must be a valid HTTP(S) URL"
256
+ end
257
+
258
+ test "enforces unique feed URLs" do
259
+ Source.create!(name: "First", feed_url: "https://example.com/feed")
260
+ duplicate = Source.new(name: "Second", feed_url: "https://example.com/feed")
261
+ assert_not duplicate.valid?
262
+ assert_includes duplicate.errors[:feed_url], "has already been taken"
263
+ end
264
+ ```
265
+
266
+ ### Scope Testing
267
+
268
+ ```ruby
269
+ test "scopes reflect expected states" do
270
+ healthy = Source.create!(name: "Healthy", feed_url: unique_url, next_fetch_at: 1.minute.ago)
271
+ inactive = Source.create!(name: "Inactive", feed_url: unique_url, active: false)
272
+
273
+ assert_includes Source.active, healthy
274
+ assert_not_includes Source.active, inactive
275
+ end
276
+ ```
277
+
278
+ ### Database Constraint Testing
279
+
280
+ ```ruby
281
+ test "database rejects invalid fetch_status values" do
282
+ source = create_source!
283
+
284
+ error = assert_raises(ActiveRecord::StatementInvalid) do
285
+ source.update_columns(fetch_status: "bogus")
286
+ end
287
+
288
+ assert_match(/check_fetch_status_values/i, error.message)
289
+ end
290
+ ```
291
+
292
+ ---
293
+
294
+ ## Library Test Patterns
295
+
296
+ ### Private Method Helpers
297
+
298
+ Some test files define private helpers to build test objects:
299
+
300
+ ```ruby
301
+ class FeedFetcherTest < ActiveSupport::TestCase
302
+ private
303
+
304
+ def build_source(name:, feed_url:, fetch_interval_minutes: 360, adaptive_fetching_enabled: true)
305
+ create_source!(
306
+ name: name,
307
+ feed_url: feed_url,
308
+ fetch_interval_minutes: fetch_interval_minutes,
309
+ adaptive_fetching_enabled: adaptive_fetching_enabled
310
+ )
311
+ end
312
+ end
313
+ ```
314
+
315
+ ### Singleton Method Stubbing
316
+
317
+ For stubbing class methods without external mocking libraries:
318
+
319
+ ```ruby
320
+ singleton = SourceMonitor::Items::ItemCreator.singleton_class
321
+ singleton.alias_method :call_without_stub, :call
322
+ singleton.define_method(:call) do |source:, entry:|
323
+ raise StandardError, "forced failure"
324
+ end
325
+
326
+ begin
327
+ # ... test logic ...
328
+ ensure
329
+ singleton.alias_method :call, :call_without_stub
330
+ singleton.remove_method :call_without_stub
331
+ end
332
+ ```
333
+
334
+ ### Minitest Mock/Stub
335
+
336
+ ```ruby
337
+ test "handles policy error" do
338
+ SourceMonitor::Fetching::RetryPolicy.stub(:new, ->(**_) { raise StandardError, "policy exploded" }) do
339
+ result = FeedFetcher.new(source: source, jitter: ->(_) { 0 }).call
340
+ assert_equal :failed, result.status
341
+ end
342
+ end
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Time Travel Pattern
348
+
349
+ Always use `ensure` to call `travel_back`:
350
+
351
+ ```ruby
352
+ test "schedules future fetch" do
353
+ travel_to Time.zone.parse("2024-01-01 10:00:00 UTC")
354
+
355
+ source = create_source!(fetch_interval_minutes: 60)
356
+ # ... perform fetch ...
357
+
358
+ source.reload
359
+ assert_equal Time.current + 45.minutes, source.next_fetch_at
360
+ ensure
361
+ travel_back
362
+ end
363
+ ```
364
+
365
+ ---
366
+
367
+ ## ActiveSupport::Notifications Testing
368
+
369
+ ```ruby
370
+ test "emits instrumentation event" do
371
+ finish_payloads = []
372
+
373
+ ActiveSupport::Notifications.subscribed(
374
+ ->(_name, _start, _finish, _id, payload) { finish_payloads << payload },
375
+ "source_monitor.fetch.finish"
376
+ ) do
377
+ FeedFetcher.new(source: source, jitter: ->(_) { 0 }).call
378
+ end
379
+
380
+ payload = finish_payloads.last
381
+ assert payload[:success]
382
+ assert_equal :fetched, payload[:status]
383
+ assert_equal 200, payload[:http_status]
384
+ assert_equal source.id, payload[:source_id]
385
+ end
386
+ ```
387
+
388
+ ---
389
+
390
+ ## Difference Assertions
391
+
392
+ ```ruby
393
+ # Single counter
394
+ assert_difference -> { Source.count }, 1 do
395
+ post "/source_monitor/sources", params: { ... }
396
+ end
397
+
398
+ # Multiple counters
399
+ assert_difference [
400
+ -> { SourceMonitor::Source.count },
401
+ -> { SourceMonitor::Item.count },
402
+ -> { SourceMonitor::FetchLog.count }
403
+ ], -1 do
404
+ delete source_monitor.source_path(source), as: :turbo_stream
405
+ end
406
+
407
+ # No change
408
+ assert_no_difference "Source.count" do
409
+ post "/source_monitor/sources", params: { source: { name: "" } }
410
+ end
411
+ ```
@@ -0,0 +1,265 @@
1
+ ---
2
+ name: sm-event-handler
3
+ description: Use when working with SourceMonitor lifecycle events and callbacks, including after_item_created, after_item_scraped, after_fetch_completed, and item processors.
4
+ allowed-tools: Read, Write, Edit, Bash, Glob, Grep
5
+ ---
6
+
7
+ # sm-event-handler: Lifecycle Events and Callbacks
8
+
9
+ Integrate with SourceMonitor's event system to respond to feed activity without monkey-patching.
10
+
11
+ ## When to Use
12
+
13
+ - Wiring host app logic to engine lifecycle events
14
+ - Building notifications, indexing, or analytics on feed activity
15
+ - Understanding event payloads and when events fire
16
+ - Debugging event handler failures
17
+ - Implementing item processors for post-processing pipelines
18
+
19
+ ## Event System Architecture
20
+
21
+ ```
22
+ Feed Fetch Pipeline
23
+ |
24
+ +-> EntryProcessor creates item
25
+ | |
26
+ | +-> Events.after_item_created(event) # ItemCreatedEvent
27
+ | +-> Events.run_item_processors(context) # ItemProcessorContext
28
+ |
29
+ +-> ItemScraper scrapes content
30
+ | |
31
+ | +-> Events.after_item_scraped(event) # ItemScrapedEvent
32
+ |
33
+ +-> Fetch completes
34
+ |
35
+ +-> Events.after_fetch_completed(event) # FetchCompletedEvent
36
+ ```
37
+
38
+ Events are dispatched synchronously. Errors in handlers are caught, logged, and do not halt the pipeline.
39
+
40
+ ## Available Events
41
+
42
+ ### `after_item_created`
43
+
44
+ Fires after a new item is created from a feed entry.
45
+
46
+ **Event struct:** `SourceMonitor::Events::ItemCreatedEvent`
47
+
48
+ | Field | Type | Description |
49
+ |---|---|---|
50
+ | `item` | `SourceMonitor::Item` | The newly created item |
51
+ | `source` | `SourceMonitor::Source` | The owning source/feed |
52
+ | `entry` | Object | The raw feed entry from Feedjira |
53
+ | `result` | Object | The creation result |
54
+ | `status` | String | Result status (e.g., `"created"`) |
55
+ | `occurred_at` | Time | When the event fired |
56
+
57
+ **Helper method:** `event.created?` -- returns true when `status == "created"`
58
+
59
+ ```ruby
60
+ config.events.after_item_created do |event|
61
+ NewItemNotifier.publish(event.item, source: event.source)
62
+ end
63
+ ```
64
+
65
+ ### `after_item_scraped`
66
+
67
+ Fires after an item has been scraped for content.
68
+
69
+ **Event struct:** `SourceMonitor::Events::ItemScrapedEvent`
70
+
71
+ | Field | Type | Description |
72
+ |---|---|---|
73
+ | `item` | `SourceMonitor::Item` | The scraped item |
74
+ | `source` | `SourceMonitor::Source` | The owning source |
75
+ | `result` | Object | The scrape result |
76
+ | `log` | `SourceMonitor::ScrapeLog` | The scrape log record |
77
+ | `status` | String | Result status |
78
+ | `occurred_at` | Time | When the event fired |
79
+
80
+ **Helper method:** `event.success?` -- returns true when `status != "failed"`
81
+
82
+ ```ruby
83
+ config.events.after_item_scraped do |event|
84
+ if event.success?
85
+ SearchIndexer.reindex(event.item)
86
+ else
87
+ ErrorTracker.report("Scrape failed for item #{event.item.id}")
88
+ end
89
+ end
90
+ ```
91
+
92
+ ### `after_fetch_completed`
93
+
94
+ Fires after a feed fetch finishes (success or failure).
95
+
96
+ **Event struct:** `SourceMonitor::Events::FetchCompletedEvent`
97
+
98
+ | Field | Type | Description |
99
+ |---|---|---|
100
+ | `source` | `SourceMonitor::Source` | The fetched source |
101
+ | `result` | Object | The fetch result |
102
+ | `status` | String | Result status |
103
+ | `occurred_at` | Time | When the event fired |
104
+
105
+ ```ruby
106
+ config.events.after_fetch_completed do |event|
107
+ Rails.logger.info "Fetch for #{event.source.name}: #{event.status}"
108
+ MetricsCollector.record_fetch(event.source, event.status, event.occurred_at)
109
+ end
110
+ ```
111
+
112
+ ## Item Processors
113
+
114
+ Item processors are a separate pipeline that runs after each entry is processed. Unlike event callbacks, they receive an `ItemProcessorContext` and are designed for lightweight normalization or denormalized writes.
115
+
116
+ **Context struct:** `SourceMonitor::Events::ItemProcessorContext`
117
+
118
+ | Field | Type | Description |
119
+ |---|---|---|
120
+ | `item` | `SourceMonitor::Item` | The processed item |
121
+ | `source` | `SourceMonitor::Source` | The owning source |
122
+ | `entry` | Object | The raw feed entry |
123
+ | `result` | Object | The processing result |
124
+ | `status` | String | Result status |
125
+ | `occurred_at` | Time | When processing occurred |
126
+
127
+ ```ruby
128
+ config.events.register_item_processor ->(context) {
129
+ SearchIndexer.index(context.item)
130
+ }
131
+
132
+ config.events.register_item_processor ->(context) {
133
+ context.item.update_column(:word_count, context.item.content&.split&.size || 0)
134
+ }
135
+ ```
136
+
137
+ ## Registering Handlers
138
+
139
+ ### Block Form
140
+ ```ruby
141
+ config.events.after_item_created do |event|
142
+ # handle event
143
+ end
144
+ ```
145
+
146
+ ### Lambda/Proc Form
147
+ ```ruby
148
+ handler = ->(event) { Analytics.track(event.item) }
149
+ config.events.after_item_created(handler)
150
+ ```
151
+
152
+ ### Callable Object Form
153
+ ```ruby
154
+ class NewItemHandler
155
+ def call(event)
156
+ Notification.send(event.item, event.source)
157
+ end
158
+ end
159
+
160
+ config.events.after_item_created(NewItemHandler.new)
161
+ ```
162
+
163
+ All handlers must respond to `#call`. Zero-arity callables are supported (called without the event argument).
164
+
165
+ ## Error Handling
166
+
167
+ Errors in event handlers are:
168
+ 1. **Caught** -- they do not propagate or halt the pipeline
169
+ 2. **Logged** -- via `Rails.logger.error` (or `warn` fallback)
170
+ 3. **Formatted** as: `[SourceMonitor] <event_name> handler <handler.inspect> failed: <ErrorClass>: <message>`
171
+
172
+ This means handlers should be idempotent where possible, since a failure does not prevent subsequent handlers from running.
173
+
174
+ ## Dispatching Internals
175
+
176
+ The `SourceMonitor::Events` module handles dispatch:
177
+
178
+ ```ruby
179
+ # lib/source_monitor/events.rb
180
+ def dispatch(event_name, event)
181
+ SourceMonitor.config.events.callbacks_for(event_name).each do |callback|
182
+ invoke(callback, event)
183
+ rescue StandardError => error
184
+ log_handler_error(event_name, callback, error)
185
+ end
186
+ end
187
+ ```
188
+
189
+ Events are dispatched from:
190
+ - `Fetching::Completion::EventPublisher` -- fires `after_fetch_completed`
191
+ - `Fetching::FeedFetcher::EntryProcessor` -- fires `after_item_created` and runs item processors
192
+ - `Scraping::ItemScraper` -- fires `after_item_scraped`
193
+
194
+ ## Common Use Cases
195
+
196
+ | Use Case | Event | Example |
197
+ |---|---|---|
198
+ | Send notifications on new items | `after_item_created` | Email, Slack, push |
199
+ | Index scraped content | `after_item_scraped` | Elasticsearch, Meilisearch |
200
+ | Track fetch statistics | `after_fetch_completed` | Custom metrics, dashboards |
201
+ | Normalize item data | `register_item_processor` | Word count, tag extraction |
202
+ | Sync to external systems | `after_item_created` | CRM, analytics, webhooks |
203
+
204
+ ## Key Source Files
205
+
206
+ | File | Purpose |
207
+ |---|---|
208
+ | `lib/source_monitor/events.rb` | Event dispatch, structs, error handling |
209
+ | `lib/source_monitor/configuration/events.rb` | Callback registration DSL |
210
+ | `lib/source_monitor/fetching/completion/event_publisher.rb` | Fetch completion dispatch |
211
+ | `lib/source_monitor/fetching/feed_fetcher/entry_processor.rb` | Item creation dispatch |
212
+ | `lib/source_monitor/scraping/item_scraper.rb` | Scrape completion dispatch |
213
+
214
+ ## References
215
+
216
+ - `reference/events-api.md` -- Full API reference with all event signatures
217
+ - `docs/configuration.md` -- Configuration documentation (Events section)
218
+
219
+ ## Testing
220
+
221
+ ```ruby
222
+ require "test_helper"
223
+
224
+ class EventHandlerTest < ActiveSupport::TestCase
225
+ setup do
226
+ SourceMonitor.reset_configuration!
227
+ @source = create_source!
228
+ end
229
+
230
+ test "after_item_created fires with correct payload" do
231
+ received = nil
232
+ SourceMonitor.configure do |config|
233
+ config.events.after_item_created { |event| received = event }
234
+ end
235
+
236
+ item = @source.items.create!(title: "Test", url: "https://example.com", external_id: "1")
237
+ SourceMonitor::Events.after_item_created(item: item, source: @source, entry: nil, result: nil)
238
+
239
+ assert_not_nil received
240
+ assert_equal item, received.item
241
+ assert_equal @source, received.source
242
+ end
243
+
244
+ test "handler errors are caught and logged" do
245
+ SourceMonitor.configure do |config|
246
+ config.events.after_fetch_completed { |_| raise "boom" }
247
+ end
248
+
249
+ # Should not raise
250
+ assert_nothing_raised do
251
+ SourceMonitor::Events.after_fetch_completed(source: @source, result: nil)
252
+ end
253
+ end
254
+ end
255
+ ```
256
+
257
+ ## Checklist
258
+
259
+ - [ ] Handler responds to `#call`
260
+ - [ ] Handler accepts the event struct or is zero-arity
261
+ - [ ] Handler is registered in `config/initializers/source_monitor.rb`
262
+ - [ ] Handler is idempotent (errors don't halt pipeline)
263
+ - [ ] Heavy work is enqueued as background jobs, not done inline
264
+ - [ ] Tests verify handler receives correct payload
265
+ - [ ] Tests verify error isolation (handler failures don't propagate)