simple-rss 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +492 -8
  4. data/Rakefile +1 -1
  5. data/examples/digest.rb +19 -0
  6. data/examples/discover.rb +14 -0
  7. data/examples/feedbag.rb +10 -0
  8. data/lib/simple-rss/discovery.rb +107 -0
  9. data/lib/simple-rss/entry_normalizer.rb +356 -0
  10. data/lib/simple-rss/http_client.rb +216 -0
  11. data/lib/simple-rss/json_entry_normalizer.rb +147 -0
  12. data/lib/simple-rss/json_feed.rb +176 -0
  13. data/lib/simple-rss/normalized_entry.rb +79 -0
  14. data/lib/simple-rss/request_errors.rb +21 -0
  15. data/lib/simple-rss/request_policy.rb +72 -0
  16. data/lib/simple-rss/xml_element.rb +136 -0
  17. data/lib/simple-rss.rb +225 -77
  18. data/simple-rss.gemspec +5 -5
  19. data/test/base/category_parsing_test.rb +217 -0
  20. data/test/base/date_ordering_test.rb +95 -0
  21. data/test/base/discovery_dependency_test.rb +29 -0
  22. data/test/base/discovery_test.rb +151 -0
  23. data/test/base/discovery_transport_test.rb +413 -0
  24. data/test/base/enumerable_test.rb +16 -0
  25. data/test/base/feedbag_integration_test.rb +21 -0
  26. data/test/base/json_feed_test.rb +372 -0
  27. data/test/base/normalized_entries_test.rb +410 -0
  28. data/test/base/normalized_fetch_test.rb +132 -0
  29. data/test/base/relation_links_test.rb +162 -0
  30. data/test/data/atom_categories.xml +23 -0
  31. data/test/data/atom_nested_link.xml +13 -0
  32. data/test/data/discovery.html +28 -0
  33. data/test/data/json_feed_1.json +75 -0
  34. data/test/data/json_feed_1_1.json +78 -0
  35. data/test/data/mixed_dates.xml +55 -0
  36. data/test/data/normalized_atom.xml +22 -0
  37. data/test/data/normalized_rss.xml +21 -0
  38. data/test/data/rss_categories.xml +15 -0
  39. data/test/support/http_server.rb +44 -0
  40. data/test/support/replace_method.rb +14 -0
  41. metadata +42 -10
@@ -0,0 +1,372 @@
1
+ require "test_helper"
2
+ require "json"
3
+ require "stringio"
4
+
5
+ class JsonFeedTest < Test::Unit::TestCase
6
+ def test_parses_json_feed_through_the_public_api
7
+ source = JSON.generate(version: "https://jsonfeed.org/version/1.1", title: "Example",
8
+ items: [{ id: "opaque:%2F:001", url: "https://example.com/1", content_text: "Hello" }])
9
+ feed = SimpleRSS.parse(source)
10
+ entry = feed.normalized_entries.first
11
+
12
+ assert_equal :json_feed, feed.feed_type
13
+ assert_equal "Example", feed.title
14
+ assert_equal "opaque:%2F:001", entry.identifier
15
+ assert_equal "https://example.com/1", entry.url
16
+ assert_equal "Hello", entry.content_text
17
+ end
18
+
19
+ def test_accepts_an_empty_json_feed
20
+ source = JSON.generate(version: "https://jsonfeed.org/version/1", title: "Example", items: [])
21
+ feed = SimpleRSS.parse(StringIO.new(source))
22
+
23
+ assert_empty feed.items
24
+ assert_empty feed.normalized_entries
25
+ assert SimpleRSS.valid?(source)
26
+ assert feed.valid?
27
+ end
28
+
29
+ def test_expired_accepts_only_booleans_when_present
30
+ assert_nil parse_items([]).expired
31
+ [true, false].each do |value|
32
+ feed = parse_items([], expired: value)
33
+ assert_equal value, feed.expired
34
+ assert_equal value, feed.raw_json["expired"]
35
+ assert_equal value, feed.to_hash[:expired]
36
+ end
37
+ ["false", "true", 0, 1, nil, [], {}].each do |value|
38
+ error = assert_raise(SimpleRSSError) { parse_items([], expired: value) }
39
+ assert_include error.message, "feed.expired must be a boolean"
40
+ end
41
+ end
42
+
43
+ def test_spec_fixtures_preserve_feed_metadata_and_extensions
44
+ %w[1 1_1].each do |version|
45
+ feed = fixture(version)
46
+ assert_equal "A feed of examples", feed.description
47
+ assert_equal "https://example.com/", feed.link
48
+ assert_equal "https://example.com/", feed.home_page_url
49
+ assert_equal "https://example.com/feed.json", feed.feed_url
50
+ assert_equal "https://example.com/older.json", feed.next_url
51
+ assert_equal "https://example.com/icon.png", feed.icon
52
+ assert_equal "https://example.com/favicon.png", feed.favicon
53
+ assert_equal true, feed.expired
54
+ assert_equal "WebSub", feed.hubs.first["type"]
55
+ assert_equal "Subscribe with a feed reader.", feed.user_comment
56
+ assert_equal [1, 2], feed.raw_json["_publisher"]["sequence"]
57
+ assert_equal "original", feed.normalized_entries.first.raw["_extension"]["value"]
58
+ end
59
+ end
60
+
61
+ def test_rss_atom_and_json_share_normalized_core_fields
62
+ entries = %w[rss atom].map do |format|
63
+ SimpleRSS.parse(File.read(File.join(__dir__, "../data/normalized_#{format}.xml"))).normalized_entries.first
64
+ end
65
+ entries << fixture("1_1").normalized_entries.first
66
+ entries.each do |entry|
67
+ assert_equal "opaque:%2F:001", entry.identifier
68
+ assert_equal "Ruby & feeds", entry.title
69
+ assert_equal "https://example.com/blog/article?one=1&two=2", entry.url
70
+ assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
71
+ assert_equal Time.utc(2026, 9, 13, 11), entry.updated_at
72
+ assert_equal "<p>Full &amp; complete.</p>", entry.content_html
73
+ assert_equal %w[ruby feeds], entry.categories
74
+ assert_equal(["https://example.com/audio/one.mp3", "https://example.com/images/two.png"], entry.attachments.map { |attachment| attachment[:url] })
75
+ assert_equal([1234, 4321], entry.attachments.map { |attachment| attachment[:size_in_bytes] })
76
+ end
77
+ end
78
+
79
+ def test_normalizes_json_specific_fields_without_losing_associations
80
+ entry = fixture("1_1").normalized_entries.first
81
+ assert_equal "Full & complete.", entry.content_text
82
+ assert_equal "A summary.", entry.summary
83
+ assert_equal :text, entry.summary_type
84
+ assert_equal "https://example.org/original", entry.external_url
85
+ assert_equal "https://example.com/image.png", entry.image
86
+ assert_equal "https://example.com/banner.png", entry.banner_image
87
+ assert_equal "en", entry.language
88
+ assert_equal(%w[alternate related], entry.links.map { |link| link[:rel] })
89
+ assert_equal ["ruby", "feeds", "ruby", " "], entry.raw["tags"]
90
+ assert_equal 3, entry.category_details.size
91
+ assert_equal(%w[Episode Cover], entry.attachments.map { |attachment| attachment[:title] })
92
+ assert_equal(%w[audio/mpeg image/png], entry.attachments.map { |attachment| attachment[:media_type] })
93
+ assert_equal 62.5, entry.attachments.first[:duration_in_seconds]
94
+ assert_equal false, entry.attachments.first[:raw]["_codec"]["lossless"]
95
+ assert_equal "date_published", entry.field_sources[:published_at]
96
+ assert_empty entry.issues
97
+ assert_nil entry.raw_xml
98
+ end
99
+
100
+ def test_authors_inherit_and_item_authors_take_precedence
101
+ %w[1 1_1].each do |version|
102
+ inherited = fixture(version).normalized_entries.first
103
+ assert_equal(["Example Editor"], inherited.authors.map { |author| author[:name] })
104
+ assert_equal "https://example.com/avatar.png", inherited.authors.first[:avatar]
105
+ assert_equal "editor", inherited.authors.first[:raw]["_profile"]["role"]
106
+ end
107
+ feed = parse_items([{ id: "1", content_text: "Hello", authors: [{ name: "First" }, { url: "https://example.com/second" }],
108
+ author: { name: "Legacy" }, language: "fr" }], authors: [{ name: "Feed" }], language: "en")
109
+ entry = feed.normalized_entries.first
110
+ assert_equal(["First", nil], entry.authors.map { |author| author[:name] })
111
+ assert_equal "https://example.com/second", entry.authors.last[:url]
112
+ assert_equal "authors", entry.field_sources[:authors]
113
+ assert_equal "fr", entry.language
114
+ assert_equal "Legacy", entry.raw["author"]["name"]
115
+ end
116
+
117
+ def test_singular_author_and_empty_authors_have_deliberate_precedence
118
+ entry = parse_items([{ id: "1", content_text: "Hello", author: { name: "Item" } }], authors: [{ name: "Feed" }]).normalized_entries.first
119
+ assert_equal(["Item"], entry.authors.map { |author| author[:name] })
120
+ entry = parse_items([{ id: "1", content_text: "Hello", authors: [], author: { name: "Legacy" } }], authors: [{ name: "Feed" }]).normalized_entries.first
121
+ assert_empty entry.authors
122
+ entry = parse_items([{ id: "1", content_text: "Hello" }], author: { name: "Legacy" }, authors: [{ name: "Current" }]).normalized_entries.first
123
+ assert_equal(["Current"], entry.authors.map { |author| author[:name] })
124
+ end
125
+
126
+ def test_version_one_ignores_later_version_fields_but_retains_them
127
+ feed = parse_items([{ id: "1", content_text: "Hello", authors: "unknown", language: 12 }],
128
+ version: "https://jsonfeed.org/version/1", author: { name: "Legacy" }, authors: "unknown", language: 12)
129
+ entry = feed.normalized_entries.first
130
+ assert_equal(["Legacy"], entry.authors.map { |author| author[:name] })
131
+ assert_nil entry.language
132
+ assert_equal "unknown", entry.raw["authors"]
133
+ assert_equal 12, feed.raw_json["language"]
134
+ end
135
+
136
+ def test_titleless_content_is_not_decoded_or_synthesized
137
+ entry = parse_items([{ id: " x:%2F&=1 ", content_text: "<b>&amp; 😀</b>" }]).normalized_entries.first
138
+ assert_nil entry.title
139
+ assert_nil entry.url
140
+ assert_nil entry.content_html
141
+ assert_nil entry.summary
142
+ assert_equal " x:%2F&=1 ", entry.identifier
143
+ assert_equal "<b>&amp; 😀</b>", entry.content_text
144
+ assert_equal "", parse_items([{ id: "empty", content_text: "" }]).normalized_entries.first.content_text
145
+ end
146
+
147
+ def test_numeric_identifiers_are_coerced_only_in_the_item_view
148
+ feed = parse_items([{ id: 42, content_text: "Hello" }, { id: 1.5, content_html: "<p>Hi</p>" }])
149
+ assert_equal ["42", "1.5"], feed.map(&:id)
150
+ assert_equal ["42", "1.5"], feed.normalized_entries.map(&:identifier)
151
+ assert_equal 42, feed.raw_json["items"].first["id"]
152
+ assert_equal 42, feed.normalized_entries.first.raw["id"]
153
+ end
154
+
155
+ def test_invalid_dates_are_inspectable_and_do_not_break_effective_date_helpers
156
+ feed = parse_items([
157
+ { id: "invalid", content_text: "One", date_published: "not a date", date_modified: "2026-09-13T11:00:00Z" },
158
+ { id: "old", content_text: "Two", date_published: "1960-01-01T00:00:00Z" },
159
+ { id: "undated", content_text: "Three", date_published: { bad: true }, date_modified: "2026-02-30T00:00:00Z" }
160
+ ])
161
+ first, old, undated = feed.normalized_entries
162
+ assert_nil first.published_at
163
+ assert_equal Time.utc(2026, 9, 13, 11), first.effective_at
164
+ assert_equal "not a date", first.raw["date_published"]
165
+ assert_equal :invalid_date, first.issues.first[:code]
166
+ assert_equal :published_at, first.issues.first[:field]
167
+ assert_equal "date_published", first.issues.first[:source]
168
+ assert_nil old.updated_at
169
+ assert_nil undated.effective_at
170
+ assert_equal 2, undated.issues.size
171
+ assert_equal %w[invalid old undated], feed.latest.map(&:id)
172
+ assert_equal ["invalid"], feed.items_since(Time.utc(2020)).map(&:id)
173
+ %w[2026-09-12 tomorrow 2026-09-12T10:00:00 2026-09-12T25:00:00Z].each do |value|
174
+ entry = parse_items([{ id: "1", content_text: "Hi", date_published: value }]).normalized_entries.first
175
+ assert_nil entry.published_at, value
176
+ assert_equal :invalid_date, entry.issues.first[:code]
177
+ end
178
+ end
179
+
180
+ def test_attachment_numeric_recovery_preserves_original_values
181
+ feed = parse_items([{ id: "1", content_text: "Hello", attachments: [
182
+ { url: "https://example.com/one", mime_type: "audio/mpeg", title: "Episode", size_in_bytes: "big", duration_in_seconds: -1 },
183
+ { url: "https://example.com/two", mime_type: "audio/ogg", title: "Episode", size_in_bytes: 0, duration_in_seconds: 0 }
184
+ ] }])
185
+ entry = feed.normalized_entries.first
186
+ assert_nil entry.attachments.first[:size_in_bytes]
187
+ assert_nil entry.attachments.first[:duration_in_seconds]
188
+ assert_equal [0, 0], entry.attachments.last.values_at(:size_in_bytes, :duration_in_seconds)
189
+ assert_equal "big", entry.attachments.first[:raw]["size_in_bytes"]
190
+ assert_equal(%w[Episode Episode], entry.attachments.map { |attachment| attachment[:title] })
191
+ assert_equal(["attachments[0].size_in_bytes", "attachments[0].duration_in_seconds"], entry.issues.map { |issue| issue[:source] })
192
+ end
193
+
194
+ def test_rfc3339_rejects_out_of_range_clock_and_offset_components
195
+ invalid = %w[
196
+ 2026-09-01T24:00:00Z 2026-09-01T24:01:00Z 2026-09-01T12:60:00Z
197
+ 2026-09-01T00:00:00+25:00 2026-09-01T00:00:00-24:00
198
+ 2026-09-01T00:00:00+00:60 2026-09-01T00:00:00-00:99
199
+ ]
200
+ invalid.each do |value|
201
+ feed = parse_items([{ id: "1", content_text: "Hi", date_published: value, date_modified: value }])
202
+ entry = feed.normalized_entries.first
203
+ assert_nil entry.published_at, value
204
+ assert_nil entry.updated_at, value
205
+ assert_equal(%w[date_published date_modified], entry.issues.map { |issue| issue[:source] })
206
+ assert_equal value, entry.raw["date_published"]
207
+ assert_empty feed.items_since(Time.utc(2026))
208
+ end
209
+ end
210
+
211
+ def test_rfc3339_accepts_boundary_offsets_and_uses_gregorian_dates
212
+ expected = {
213
+ "2026-09-01T23:59:59.125+23:59" => Time.utc(2026, 9, 1, 0, 0, 59.125),
214
+ "2026-09-01t00:00:00-23:59" => Time.utc(2026, 9, 1, 23, 59),
215
+ "1582-10-10T00:00:00Z" => Time.utc(1582, 10, 10)
216
+ }
217
+ expected.each do |value, time|
218
+ entry = parse_items([{ id: "1", content_text: "Hi", date_published: value }]).normalized_entries.first
219
+ assert_equal time, entry.published_at
220
+ assert_empty entry.issues
221
+ end
222
+ entry = parse_items([{ id: "1", content_text: "Hi", date_published: "1500-02-29T00:00:00Z" }]).normalized_entries.first
223
+ assert_nil entry.published_at
224
+ assert_equal :invalid_date, entry.issues.first[:code]
225
+ end
226
+
227
+ def test_raw_and_serialized_representations_are_explicit_and_immutable
228
+ feed = fixture("1_1")
229
+ before = feed.to_json
230
+ entry = feed.normalized_entries.first
231
+ assert_equal before, feed.to_json
232
+ assert_equal JSON.parse(before), JSON.parse(JSON.generate(feed.as_json))
233
+ assert_equal feed.as_json, feed.to_hash
234
+ assert_equal "2026-09-12T10:00:00+00:00", feed.to_hash[:items].first[:pubDate]
235
+ assert_equal "2026-09-12T10:00:00Z", feed.to_hash[:items].first[:date_published]
236
+ assert_equal [1, 2], feed.to_hash[:_publisher]["sequence"]
237
+ assert_equal "original", JSON.parse(before)["items"].first["_extension"]["value"]
238
+ assert_raise(FrozenError) { feed.raw_json["_publisher"]["sequence"] << 3 }
239
+ assert_raise(FrozenError) { entry.raw["_extension"]["value"].replace("changed") }
240
+ assert_raise(FrozenError) { entry.authors.first[:raw]["name"].replace("changed") }
241
+ assert_raise(SimpleRSSError) { feed.to_xml }
242
+ assert_raise(SimpleRSSError) { feed.to_xml(format: :atom) }
243
+ assert_raise(ArgumentError) { feed.normalized_entries(mappings: { content_text: "custom" }) }
244
+ end
245
+
246
+ def test_legacy_helpers_and_dedupe_retain_the_original_entry_context
247
+ feed = fixture("1_1")
248
+ assert_equal feed.first, feed.search("Full").first
249
+ assert_equal feed.first, feed.items_by_category("RUBY").first
250
+ assert feed.first.has_media?
251
+ assert_equal "https://example.com/audio/one.mp3", feed.enclosures.first[:url]
252
+ assert_include feed.images, "https://example.com/image.png"
253
+ feed.items << feed.first
254
+ assert_equal 1, feed.dedupe.items.size
255
+ assert_equal "opaque:%2F:001", feed.normalized_entries.first.identifier
256
+ feed.items << { id: "unrelated" }
257
+ assert_raise(SimpleRSSError) { feed.normalized_entries }
258
+ end
259
+
260
+ def test_utf8_bom_whitespace_and_readable_binary_io
261
+ source = JSON.generate(version: "https://jsonfeed.org/version/1.1", title: "😀", items: [])
262
+ [source, " \r\n\t#{source}", "\uFEFF \n#{source}"].each do |body|
263
+ feed = SimpleRSS.parse(StringIO.new(body.b))
264
+ assert_equal "😀", feed.title
265
+ assert_equal body.b, feed.source
266
+ end
267
+ assert_raise(SimpleRSSError) { SimpleRSS.parse(" \uFEFF#{source}") }
268
+ assert_raise(SimpleRSSError) { SimpleRSS.parse("\uFEFF\uFEFF#{source}") }
269
+ end
270
+
271
+ def test_invalid_utf8_is_rejected_before_exposing_feed_data
272
+ source = JSON.generate(version: "https://jsonfeed.org/version/1.1", title: "Example", items: [{ id: "1", content_text: "payload" }])
273
+ ["\xFF".b, "\xC0\x80".b, "\xE2\x82".b].each do |invalid|
274
+ body = source.b.sub("payload", invalid)
275
+ [body, StringIO.new(body)].each do |input|
276
+ error = assert_raise(SimpleRSSError) { SimpleRSS.parse(input) }
277
+ assert_include error.message, "invalid UTF-8"
278
+ end
279
+ assert_false SimpleRSS.valid?(body)
280
+ end
281
+ end
282
+
283
+ def test_overflowing_json_numbers_cannot_collapse_identifiers_or_break_serialization
284
+ sources = [
285
+ '{"version":"https://jsonfeed.org/version/1.1","title":"QA","items":[{"id":1e999,"content_text":"One"},{"id":2e999,"content_text":"Two"}]}',
286
+ '{"version":"https://jsonfeed.org/version/1.1","title":"QA","items":[],"_extension":{"numbers":[-1e999]}}',
287
+ '{"version":"https://jsonfeed.org/version/1.1","title":"QA","items":[{"id":"one","content_text":"One","attachments":[{"url":"https://example.com/audio","mime_type":"audio/mpeg","size_in_bytes":1e999}]}]}'
288
+ ]
289
+ sources.each do |source|
290
+ error = assert_raise(SimpleRSSError) { SimpleRSS.parse(source) }
291
+ assert_include error.message, "number exceeds the supported range"
292
+ assert_false SimpleRSS.valid?(source)
293
+ end
294
+ identifier = 10**100
295
+ feed = parse_items([{ id: identifier, content_text: "Large integer" }], _number: 1.5)
296
+ assert_equal identifier.to_s, feed.normalized_entries.first.identifier
297
+ assert_equal identifier, feed.raw_json["items"].first["id"]
298
+ assert_equal 1.5, JSON.parse(feed.to_json)["_number"]
299
+ end
300
+
301
+ def test_invalid_json_and_structures_raise_library_errors
302
+ ['{"version":', "[]", "null", "42", "true", '"hello"',
303
+ '{"description":"<channel><item>not XML</item></channel>"}'].each do |source|
304
+ assert_raise(SimpleRSSError, source) { SimpleRSS.parse(source) }
305
+ assert_equal false, SimpleRSS.valid?(source)
306
+ end
307
+ [{ version: "https://jsonfeed.org/version/2" }, { version: nil }, { title: nil }, { items: nil },
308
+ { items: {} }, { home_page_url: [] }, { authors: {} }, { authors: [{}] }, { author: false },
309
+ { hubs: [nil] }, { hubs: [{ type: "WebSub" }] }].each do |override|
310
+ assert_raise(SimpleRSSError, override.inspect) { parse_items([], **override) }
311
+ end
312
+ [nil, [], {}, { id: "1" }, { id: nil, content_text: "Hi" }, { id: [], content_text: "Hi" },
313
+ { id: true, content_text: "Hi" }, { id: " ", content_text: "Hi" },
314
+ { id: "1", content_text: 1 }, { id: "1", content_text: "Hi", tags: {} },
315
+ { id: "1", content_text: "Hi", tags: [false] }, { id: "1", content_text: "Hi", authors: [nil] },
316
+ { id: "1", content_text: "Hi", attachments: [nil] },
317
+ { id: "1", content_text: "Hi", attachments: [{ url: "https://example.com/file" }] }].each do |item|
318
+ error = assert_raise(SimpleRSSError, item.inspect) { parse_items([item]) }
319
+ assert_include error.message, "items[0]"
320
+ end
321
+ end
322
+
323
+ def test_format_dispatch_preserves_tolerant_xml_prefixes
324
+ source = 'Unexpected prefix <rss version="2.0"><channel><title>Example</title><item><title>Post</title></item></channel></rss>'
325
+ assert_equal "Post", SimpleRSS.parse(source).first.title
326
+ source = JSON.generate("<channel><title>Not an XML feed</title><item>Wrong</item></channel>")
327
+ assert_raise(SimpleRSSError) { SimpleRSS.parse(source) }
328
+ end
329
+
330
+ def test_blank_urls_never_become_feed_urls
331
+ entry = parse_items([{ id: "1", url: " ", image: "", content_text: "Hi" }], feed_url: "https://example.com/feed.json").normalized_entries.first
332
+ assert_nil entry.url
333
+ assert_nil entry.image
334
+ end
335
+
336
+ def test_xml_behavior_and_configuration_remain_unchanged
337
+ source = '<rss version="2.0"><channel><title>Empty</title></channel></rss>'
338
+ tags = [SimpleRSS.feed_tags.dup, SimpleRSS.item_tags.dup]
339
+ feed = SimpleRSS.parse(source)
340
+ before = [feed.to_json, feed.to_hash, feed.to_xml]
341
+ fixture("1_1").normalized_entries
342
+ assert_equal before, [feed.to_json, feed.to_hash, feed.to_xml]
343
+ assert_equal tags, [SimpleRSS.feed_tags, SimpleRSS.item_tags]
344
+ assert_nil feed.raw_json
345
+ assert_equal false, feed.valid?
346
+ assert SimpleRSS.valid?(source)
347
+ end
348
+
349
+ def test_relative_and_invalid_urls_are_preserved_with_normalization_issues
350
+ feed = parse_items([{ id: "relative", url: "article", external_url: "bad space", content_text: "Hi", image: "image.png" }])
351
+ entry = feed.normalized_entries.first
352
+ assert_equal "article", entry.url
353
+ assert_equal(%i[relative_url_without_base invalid_url relative_url_without_base], entry.issues.map { |issue| issue[:code] })
354
+ entry = feed.normalized_entries(source_url: "https://example.com/blog/feed.json").first
355
+ assert_equal "https://example.com/blog/article", entry.url
356
+ assert_equal "https://example.com/blog/image.png", entry.image
357
+ assert_equal "bad space", entry.external_url
358
+ assert_equal "article", entry.raw["url"]
359
+ assert_equal "https://example.com/blog/article", entry.content_base_url
360
+ end
361
+
362
+ private
363
+
364
+ def fixture(version)
365
+ SimpleRSS.parse(File.read(File.join(__dir__, "../data/json_feed_#{version}.json")))
366
+ end
367
+
368
+ def parse_items(items, **fields)
369
+ source = { version: "https://jsonfeed.org/version/1.1", title: "Example", items: items }.merge(fields)
370
+ SimpleRSS.parse(JSON.generate(source))
371
+ end
372
+ end