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.
- checksums.yaml +4 -4
- data/.claude/skills/sm-architecture/SKILL.md +233 -0
- data/.claude/skills/sm-architecture/reference/extraction-patterns.md +192 -0
- data/.claude/skills/sm-architecture/reference/module-map.md +194 -0
- data/.claude/skills/sm-configuration-setting/SKILL.md +264 -0
- data/.claude/skills/sm-configuration-setting/reference/settings-catalog.md +248 -0
- data/.claude/skills/sm-configuration-setting/reference/settings-pattern.md +297 -0
- data/.claude/skills/sm-configure/SKILL.md +153 -0
- data/.claude/skills/sm-configure/reference/configuration-reference.md +321 -0
- data/.claude/skills/sm-dashboard-widget/SKILL.md +344 -0
- data/.claude/skills/sm-dashboard-widget/reference/dashboard-patterns.md +304 -0
- data/.claude/skills/sm-domain-model/SKILL.md +188 -0
- data/.claude/skills/sm-domain-model/reference/model-graph.md +114 -0
- data/.claude/skills/sm-domain-model/reference/table-structure.md +348 -0
- data/.claude/skills/sm-engine-migration/SKILL.md +395 -0
- data/.claude/skills/sm-engine-migration/reference/migration-conventions.md +255 -0
- data/.claude/skills/sm-engine-test/SKILL.md +302 -0
- data/.claude/skills/sm-engine-test/reference/test-helpers.md +259 -0
- data/.claude/skills/sm-engine-test/reference/test-patterns.md +411 -0
- data/.claude/skills/sm-event-handler/SKILL.md +265 -0
- data/.claude/skills/sm-event-handler/reference/events-api.md +229 -0
- data/.claude/skills/sm-health-rule/SKILL.md +327 -0
- data/.claude/skills/sm-health-rule/reference/health-system.md +269 -0
- data/.claude/skills/sm-host-setup/SKILL.md +223 -0
- data/.claude/skills/sm-host-setup/reference/initializer-template.md +195 -0
- data/.claude/skills/sm-host-setup/reference/setup-checklist.md +134 -0
- data/.claude/skills/sm-job/SKILL.md +263 -0
- data/.claude/skills/sm-job/reference/job-conventions.md +245 -0
- data/.claude/skills/sm-model-extension/SKILL.md +287 -0
- data/.claude/skills/sm-model-extension/reference/extension-api.md +317 -0
- data/.claude/skills/sm-pipeline-stage/SKILL.md +254 -0
- data/.claude/skills/sm-pipeline-stage/reference/completion-handlers.md +152 -0
- data/.claude/skills/sm-pipeline-stage/reference/entry-processing.md +191 -0
- data/.claude/skills/sm-pipeline-stage/reference/feed-fetcher-architecture.md +198 -0
- data/.claude/skills/sm-scraper-adapter/SKILL.md +284 -0
- data/.claude/skills/sm-scraper-adapter/reference/adapter-contract.md +167 -0
- data/.claude/skills/sm-scraper-adapter/reference/example-adapter.md +274 -0
- data/.vbw-planning/.notification-log.jsonl +102 -0
- data/.vbw-planning/.session-log.jsonl +505 -0
- data/AGENTS.md +20 -57
- data/CHANGELOG.md +19 -0
- data/CLAUDE.md +44 -1
- data/CONTRIBUTING.md +5 -5
- data/Gemfile.lock +20 -21
- data/README.md +18 -5
- data/VERSION +1 -0
- data/docs/deployment.md +1 -1
- data/docs/setup.md +4 -4
- data/lib/source_monitor/setup/skills_installer.rb +94 -0
- data/lib/source_monitor/setup/workflow.rb +17 -2
- data/lib/source_monitor/version.rb +1 -1
- data/lib/tasks/source_monitor_setup.rake +58 -0
- data/source_monitor.gemspec +1 -0
- 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)
|