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,410 @@
1
+ require "test_helper"
2
+
3
+ class NormalizedEntriesTest < Test::Unit::TestCase
4
+ def test_equivalent_rss_and_atom_entries_share_one_interface
5
+ entries = %w[rss atom].map { |format| fixture(format).normalized_entries.first }
6
+
7
+ entries.each do |entry|
8
+ assert_equal "opaque:%2F:001", entry.identifier
9
+ assert_equal "Ruby & feeds", entry.title
10
+ assert_equal "https://example.com/blog/article?one=1&two=2", entry.url
11
+ assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
12
+ assert_equal Time.utc(2026, 9, 13, 11), entry.updated_at
13
+ assert_equal "<p>Full &amp; complete.</p>", entry.content_html
14
+ assert_nil entry.content_text
15
+ assert_equal "<p>A summary.</p>", entry.summary
16
+ assert_equal :html, entry.summary_type
17
+ assert_equal %w[ruby feeds], entry.categories
18
+ assert_equal(["https://example.com/audio/one.mp3", "https://example.com/images/two.png"], entry.attachments.map { |attachment| attachment[:url] })
19
+ assert_equal(%w[audio/mpeg image/png], entry.attachments.map { |attachment| attachment[:media_type] })
20
+ assert_equal([1234, 4321], entry.attachments.map { |attachment| attachment[:size_in_bytes] })
21
+ assert_empty entry.issues
22
+ end
23
+ end
24
+
25
+ def test_normalization_preserves_raw_access_serialization_and_configuration
26
+ feed = fixture("atom")
27
+ before = [feed.source.dup, feed.to_hash, feed.to_json, feed.to_xml, SimpleRSS.item_tags.dup, SimpleRSS.feed_tags.dup]
28
+
29
+ first = feed.normalized_entries.first
30
+ second = feed.normalized_entries.first
31
+
32
+ assert_equal first.to_h, second.to_h
33
+ assert_equal feed.first, first.raw
34
+ assert_not_same feed.first, first.raw
35
+ assert_equal before, [feed.source, feed.to_hash, feed.to_json, feed.to_xml, SimpleRSS.item_tags, SimpleRSS.feed_tags]
36
+ assert_raise(FrozenError) { first.categories << "changed" }
37
+ assert_raise(FrozenError) { first.raw[:title].replace("changed") }
38
+ assert_equal "Ruby &amp; feeds", feed.first.title
39
+ end
40
+
41
+ def test_link_selection_honors_default_and_iana_relations_and_preserves_metadata
42
+ entry = atom_entry(<<~XML)
43
+ <link rel="self" href="https://example.com/api/1"/>
44
+ <link type="application/pdf" href="https://example.com/article.pdf"/>
45
+ <link rel="http://www.iana.org/assignments/relation/alternate" type="text/html; charset=utf-8"
46
+ title="Web &amp; mobile" hreflang="en" href="https://example.com/article">
47
+ <extra href="https://wrong.example.com/"/>
48
+ </link>
49
+ XML
50
+
51
+ assert_equal "https://example.com/article", entry.url
52
+ assert_equal 3, entry.links.size
53
+ assert_equal "Web & mobile", entry.links.last[:raw][:attributes]["title"]
54
+ assert_equal "en", entry.links.last[:raw][:attributes]["hreflang"]
55
+ assert_include entry.links.last[:raw][:content], "wrong.example.com"
56
+ assert_nil atom_entry('<link rel="self" href="https://example.com/api/1"/>').url
57
+ assert_equal "https://example.com/default", atom_entry('<link href="https://example.com/default"/>').url
58
+ end
59
+
60
+ def test_relative_urls_use_source_url_and_each_xml_base_without_changing_identifiers
61
+ source = <<~XML
62
+ <feed xmlns="http://www.w3.org/2005/Atom" xml:base="../blog/">
63
+ <title>Base URLs</title>
64
+ <entry xml:base="posts/">
65
+ <id>../opaque%2Fid</id>
66
+ <link xml:base="../../articles/" href="one?tag=ruby&amp;page=2#section"/>
67
+ <link rel="enclosure" href="//cdn.example.com/one.mp3"/>
68
+ </entry>
69
+ </feed>
70
+ XML
71
+ feed = SimpleRSS.parse(source, source_url: "https://example.com/feeds/current.xml")
72
+ entry = feed.normalized_entries.first
73
+
74
+ assert_equal "https://example.com/articles/one?tag=ruby&page=2#section", entry.url
75
+ assert_equal "https://cdn.example.com/one.mp3", entry.attachments.first[:url]
76
+ assert_equal "../opaque%2Fid", entry.identifier
77
+ assert_equal "https://override.example.com/articles/one?tag=ruby&page=2#section",
78
+ feed.normalized_entries(source_url: "https://override.example.com/feeds/current.xml").first.url
79
+ assert_equal "https://example.com/feeds/current.xml", feed.source_url
80
+ end
81
+
82
+ def test_relative_and_invalid_urls_remain_inspectable_without_a_usable_base
83
+ entry = atom_entry('<link href="../article"/>')
84
+ assert_equal "../article", entry.url
85
+ assert_include entry.issues, { field: :url, code: :relative_url_without_base, value: "../article", source: "link" }
86
+
87
+ entry = atom_entry('<link href="http://[broken"/>')
88
+ assert_equal "http://[broken", entry.url
89
+ assert_equal :invalid_url, entry.issues.first[:code]
90
+
91
+ entry = atom_entry('<link xml:base="http://[broken" href="article"/>', source_url: "https://example.com/")
92
+ assert_equal "article", entry.url
93
+ assert_include entry.issues.map { |issue| issue[:field] }, :base_url
94
+ end
95
+
96
+ def test_publication_and_update_dates_remain_distinct_and_invalid_dates_are_preserved
97
+ entry = atom_entry("<published>not-a-date</published><updated>2026-09-13T11:00:00Z</updated>")
98
+
99
+ assert_nil entry.published_at
100
+ assert_equal Time.utc(2026, 9, 13, 11), entry.updated_at
101
+ assert_equal entry.updated_at, entry.effective_at
102
+ assert_equal "not-a-date", entry.raw[:published]
103
+ assert_include entry.issues, { field: :published_at, code: :invalid_date, value: "not-a-date", source: "published" }
104
+ assert_nil atom_entry("<updated>broken</updated>").effective_at
105
+
106
+ entry = rss_entry("<pubDate>broken</pubDate><dc:date>2026-09-12T10:00:00Z</dc:date><modified>broken too</modified>")
107
+ assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
108
+ assert_nil entry.updated_at
109
+ assert_equal "dc:date", entry.field_sources[:published_at]
110
+ assert_equal 2, entry.issues.size
111
+ end
112
+
113
+ def test_atom_content_types_preserve_text_html_cdata_and_summary_boundaries
114
+ entry = atom_entry("<content>&lt;b&gt;literal &amp; text&lt;/b&gt;</content><summary>Short version</summary>")
115
+ assert_equal "<b>literal & text</b>", entry.content_text
116
+ assert_nil entry.content_html
117
+ assert_equal "Short version", entry.summary
118
+ assert_equal :text, entry.summary_type
119
+
120
+ entry = atom_entry('<content type="html"><![CDATA[<p>A &amp; B</p>]]></content>')
121
+ assert_equal "<p>A &amp; B</p>", entry.content_html
122
+ assert_nil entry.content_text
123
+ assert_equal "content", entry.field_sources[:content_html]
124
+
125
+ entry = atom_entry('<summary type="html">&lt;p&gt;Summary only&lt;/p&gt;</summary>')
126
+ assert_nil entry.content_html
127
+ assert_nil entry.content_text
128
+ assert_equal "<p>Summary only</p>", entry.summary
129
+ assert_nil rss_entry("<description>Summary only</description>").content_text
130
+ end
131
+
132
+ def test_external_unsupported_and_invalid_text_content_do_not_become_plain_text
133
+ entry = atom_entry('<content type="text/plain" src="https://example.com/full.txt">Ignored inline body</content>')
134
+ assert_equal "https://example.com/full.txt", entry.content_url
135
+ assert_nil entry.content_text
136
+ assert_nil entry.content_html
137
+
138
+ entry = atom_entry('<content type="application/octet-stream">YWJj</content>')
139
+ assert_nil entry.content_text
140
+ assert_nil entry.content_html
141
+ assert_equal "YWJj", entry.raw[:content]
142
+ assert_equal :unsupported_content_type, entry.issues.first[:code]
143
+
144
+ entry = atom_entry('<content type="text"><b>Actual markup</b></content>')
145
+ assert_nil entry.content_text
146
+ assert_equal :unexpected_markup, entry.issues.first[:code]
147
+ end
148
+
149
+ def test_xhtml_content_excludes_its_container_and_retains_its_base
150
+ entry = atom_entry(<<~XML)
151
+ <content type="xhtml" xml:base="https://example.com/articles/">
152
+ <div xmlns="http://www.w3.org/1999/xhtml"><p>A &amp; B <a href="one">link</a></p></div>
153
+ </content>
154
+ XML
155
+ assert_equal '<p>A &amp; B <a href="one">link</a></p>', entry.content_html
156
+ assert_equal "https://example.com/articles/", entry.content_base_url
157
+ assert_nil entry.content_text
158
+ assert_empty entry.issues
159
+
160
+ entry = atom_entry('<content type="xhtml"><div xmlns="urn:foreign">Wrong namespace</div></content>')
161
+ assert_nil entry.content_html
162
+ assert_equal :invalid_xhtml, entry.issues.first[:code]
163
+ end
164
+
165
+ def test_custom_full_text_mappings_are_local_and_take_precedence_without_global_tags
166
+ source = <<~XML
167
+ <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:body="urn:example:body">
168
+ <channel><title>Custom content</title><item>
169
+ <description>Summary</description>
170
+ <content:encoded><![CDATA[<p>Default body</p>]]></content:encoded>
171
+ <full-text><![CDATA[<p>Complete body</p>]]></full-text>
172
+ <body:plain>Complete text</body:plain>
173
+ <extension><full-text>Nested body</full-text></extension>
174
+ </item></channel>
175
+ </rss>
176
+ XML
177
+ tags = [SimpleRSS.feed_tags.dup, SimpleRSS.item_tags.dup]
178
+ feed = SimpleRSS.parse(source)
179
+ entry = feed.normalized_entries(mappings: { content_html: "full-text", content_text: "{urn:example:body}plain" }).first
180
+
181
+ assert_equal "<p>Complete body</p>", entry.content_html
182
+ assert_equal "Complete text", entry.content_text
183
+ assert_equal "Summary", entry.summary
184
+ assert_equal "full-text", entry.field_sources[:content_html]
185
+ assert_equal "body:plain", entry.field_sources[:content_text]
186
+ assert_include entry.raw_xml, "<full-text>"
187
+ assert_equal "<p>Default body</p>", feed.normalized_entries.first.content_html
188
+ assert_equal "<p>Default body</p>", SimpleRSS.parse(source).normalized_entries.first.content_html
189
+ assert_equal tags, [SimpleRSS.feed_tags, SimpleRSS.item_tags]
190
+ end
191
+
192
+ def test_categories_preserve_labels_schemes_duplicates_and_source_aware_keywords
193
+ entry = fixture("atom").normalized_entries.first
194
+ assert_equal %w[ruby feeds], entry.categories
195
+ assert_equal(%w[ruby feeds ruby], entry.category_details.map { |category| category[:term] })
196
+ assert_equal "Ruby language", entry.category_details.first[:label]
197
+ assert_equal "urn:topics", entry.category_details.first[:scheme]
198
+ assert_equal "ruby", entry.category_details.first[:raw][:attributes]["term"]
199
+
200
+ source = <<~XML
201
+ <rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
202
+ <channel><title>Keywords</title><item>
203
+ <category>ruby, rails</category><dc:subject>ruby; feeds</dc:subject>
204
+ <media:keywords>audio, video</media:keywords><keywords>one | two</keywords>
205
+ </item></channel>
206
+ </rss>
207
+ XML
208
+ feed = SimpleRSS.parse(source)
209
+ assert_equal ["ruby, rails", "ruby; feeds", "audio, video"], feed.normalized_entries.first.categories
210
+ mappings = { categories: [{ tag: "dc:subject", separator: ";" }, { tag: "keywords", separator: "|" }] }
211
+ assert_equal ["ruby, rails", "ruby", "feeds", "audio, video", "one", "two"], feed.normalized_entries(mappings: mappings).first.categories
212
+ end
213
+
214
+ def test_multiple_attachments_keep_attributes_and_invalid_numbers_with_their_urls
215
+ entry = rss_entry(<<~XML)
216
+ <enclosure url="https://example.com/one" type="audio/mpeg" length="0"/>
217
+ <media:group xml:base="https://example.com/media/">
218
+ <media:content url="two" type="video/mp4" fileSize="123" duration="12.5"/>
219
+ <media:content url="three" type="image/png" fileSize="no-size" duration="forever"/>
220
+ </media:group>
221
+ <description><![CDATA[<enclosure url="https://wrong.example.com/"/>]]></description>
222
+ XML
223
+ first, second, third = entry.attachments
224
+ assert_equal 3, entry.attachments.size
225
+ assert_equal 0, first[:size_in_bytes]
226
+ assert_equal "https://example.com/media/two", second[:url]
227
+ assert_equal "video/mp4", second[:media_type]
228
+ assert_equal 123, second[:size_in_bytes]
229
+ assert_equal 12.5, second[:duration_in_seconds]
230
+ assert_equal "https://example.com/media/three", third[:url]
231
+ assert_nil third[:size_in_bytes]
232
+ assert_nil third[:duration_in_seconds]
233
+ assert_equal "no-size", third[:raw][:attributes]["fileSize"]
234
+ assert_equal "forever", third[:raw][:attributes]["duration"]
235
+ assert_equal(%i[size_in_bytes duration_in_seconds], entry.issues.map { |issue| issue[:field] })
236
+ end
237
+
238
+ def test_single_attachment_can_use_itunes_duration_without_applying_it_to_multiple_files
239
+ entry = rss_entry('<enclosure url="https://example.com/one"/><itunes:duration>1:02:03</itunes:duration>')
240
+ assert_equal 3723, entry.attachments.first[:duration_in_seconds]
241
+ assert_equal "itunes:duration", entry.attachments.first[:raw_duration][:name]
242
+
243
+ entry = rss_entry('<enclosure url="https://example.com/one"/><itunes:duration>1:99</itunes:duration>')
244
+ assert_nil entry.attachments.first[:duration_in_seconds]
245
+ assert_equal :invalid_number, entry.issues.first[:code]
246
+
247
+ entry = rss_entry('<enclosure url="https://example.com/one"/><enclosure url="https://example.com/two"/><itunes:duration>42</itunes:duration>')
248
+ assert_equal([nil, nil], entry.attachments.map { |attachment| attachment[:duration_in_seconds] })
249
+ end
250
+
251
+ def test_nested_foreign_and_commented_metadata_cannot_supply_normalized_fields
252
+ entry = atom_entry(<<~XML)
253
+ <!-- <link href="https://wrong.example.com/comment"/><category term="comment"/> -->
254
+ <![CDATA[<content type="html">Wrong body</content>]]>
255
+ <source><id>Wrong id</id><link href="https://wrong.example.com/source"/><category term="source"/></source>
256
+ <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><link href="https://wrong.example.com/body"/></div></content>
257
+ <id xmlns="urn:foreign">Foreign id</id>
258
+ <link xmlns="urn:foreign" href="https://wrong.example.com/foreign"/>
259
+ <category xmlns="" term="reset">Reset</category>
260
+ <published xmlns="urn:foreign">2026-01-01T00:00:00Z</published>
261
+ <id>Actual id</id><category term="actual"/>
262
+ XML
263
+ assert_equal "Actual id", entry.identifier
264
+ assert_nil entry.url
265
+ assert_nil entry.published_at
266
+ assert_equal ["actual"], entry.categories
267
+ assert_empty entry.attachments
268
+ assert_empty entry.links
269
+ end
270
+
271
+ def test_entry_order_and_deduplication_keep_original_xml_bound_to_the_correct_hash
272
+ source = <<~XML
273
+ <rss version="2.0"><channel><title>Identical raw items</title>
274
+ <item><guid>same</guid><full-text>First body</full-text></item>
275
+ <item><guid>same</guid><full-text>Second body</full-text></item>
276
+ </channel></rss>
277
+ XML
278
+ feed = SimpleRSS.parse(source)
279
+ mappings = { content_text: "full-text" }
280
+ assert_equal ["First body", "Second body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
281
+ feed.items.reverse!
282
+ assert_equal ["Second body", "First body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
283
+ feed.dedupe
284
+ assert_equal ["Second body"], feed.normalized_entries(mappings: mappings).map(&:content_text)
285
+ end
286
+
287
+ def test_empty_fields_do_not_invent_identity_content_dates_or_collections
288
+ entry = atom_entry('<title></title><category term=" "/><link href=""/>')
289
+ assert_nil entry.identifier
290
+ assert_nil entry.url
291
+ assert_nil entry.title
292
+ assert_nil entry.published_at
293
+ assert_nil entry.updated_at
294
+ assert_nil entry.content_html
295
+ assert_nil entry.content_text
296
+ assert_nil entry.summary
297
+ assert_empty entry.categories
298
+ assert_empty entry.attachments
299
+ end
300
+
301
+ def test_invalid_mapping_configuration_fails_with_clear_errors
302
+ feed = fixture("rss")
303
+ [nil, { unknown: "tag" }, { content_html: [] }, { content_text: "" }, { categories: "keywords" },
304
+ { categories: [{ tag: "keywords", separator: "" }] }, { categories: [{ tag: nil }] }].each do |mappings|
305
+ assert_raise(ArgumentError) { feed.normalized_entries(mappings: mappings) }
306
+ end
307
+ end
308
+
309
+ def test_rdf_item_bases_and_namespaces_do_not_inherit_from_a_sibling_channel
310
+ source = <<~XML
311
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/"
312
+ xmlns:date="http://purl.org/dc/elements/1.1/" xml:base="https://example.com/">
313
+ <channel xml:base="wrong/" xmlns:date="urn:foreign"><title>Example</title></channel>
314
+ <item><title>First</title><link>article</link><date:date>2026-09-12T10:00:00Z</date:date><date:subject>ruby</date:subject></item>
315
+ </rdf:RDF>
316
+ XML
317
+ entry = SimpleRSS.parse(source).normalized_entries.first
318
+ assert_equal "https://example.com/article", entry.url
319
+ assert_equal Time.utc(2026, 9, 12, 10), entry.published_at
320
+ assert_equal ["ruby"], entry.categories
321
+ end
322
+
323
+ def test_plain_xml_text_ignores_comments_and_processing_instructions
324
+ entry = atom_entry("<id>opaque<!-- not identity -->id</id><content>Before<!-- not text -->after<?note ignored?></content>")
325
+ assert_equal "opaqueid", entry.identifier
326
+ assert_equal "Beforeafter", entry.content_text
327
+ end
328
+
329
+ def test_atom_authors_inherit_from_source_then_feed_and_keep_uri_bases
330
+ source = <<~XML
331
+ <feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://example.com/">
332
+ <title>Authors</title>
333
+ <author><name>Editorial team</name><uri>about</uri></author>
334
+ <entry><id>one</id></entry>
335
+ <entry><id>two</id><source xml:base="source/"><author><name>Source team</name><uri>about</uri></author></source></entry>
336
+ <entry><id>three</id><author><name>Entry team</name><uri>entry</uri></author><author><name>Guest team</name></author></entry>
337
+ </feed>
338
+ XML
339
+ entries = SimpleRSS.parse(source).normalized_entries
340
+ assert_equal(["Editorial team"], entries[0].authors.map { |author| author[:name] })
341
+ assert_equal "https://example.com/about", entries[0].authors.first[:url]
342
+ assert_equal(["Source team"], entries[1].authors.map { |author| author[:name] })
343
+ assert_equal "https://example.com/source/about", entries[1].authors.first[:url]
344
+ assert_equal(["Entry team", "Guest team"], entries[2].authors.map { |author| author[:name] })
345
+ end
346
+
347
+ def test_prefixed_xhtml_preserves_content_and_the_div_base_without_prefixing_html_tags
348
+ entry = atom_entry(<<~XML)
349
+ <content type="xhtml" xml:base="https://example.com/">
350
+ <html:div xmlns:html="http://www.w3.org/1999/xhtml" xml:base="articles/">
351
+ <html:p>Read <html:a href="one">this</html:a> &amp; more.</html:p>
352
+ </html:div>
353
+ </content>
354
+ XML
355
+ assert_equal '<p>Read <a href="one">this</a> &amp; more.</p>', entry.content_html
356
+ assert_equal "https://example.com/articles/", entry.content_base_url
357
+ assert_equal "content", entry.field_sources[:content_html]
358
+ assert_include entry.raw_xml, "<html:div"
359
+ end
360
+
361
+ def test_content_media_type_parameters_and_rss_link_attributes_do_not_change_meaning
362
+ entry = atom_entry('<content type="text/html; charset=utf-8">&lt;p&gt;Body&lt;/p&gt;</content>')
363
+ assert_equal "<p>Body</p>", entry.content_html
364
+ assert_nil entry.content_text
365
+
366
+ entry = rss_entry('<link rel="self">https://example.com/article</link>')
367
+ assert_equal "https://example.com/article", entry.url
368
+ end
369
+
370
+ def test_media_groups_only_contribute_media_content_attachments
371
+ entry = rss_entry(<<~XML)
372
+ <media:group>
373
+ <enclosure url="https://wrong.example.com/nested-rss"/>
374
+ <link xmlns="http://www.w3.org/2005/Atom" rel="enclosure" href="https://wrong.example.com/nested-atom"/>
375
+ <media:content url="https://example.com/video" type="video/mp4"/>
376
+ </media:group>
377
+ XML
378
+ assert_equal(["https://example.com/video"], entry.attachments.map { |attachment| attachment[:url] })
379
+ end
380
+
381
+ def test_invalid_attachment_duration_is_not_replaced_by_an_item_level_duration
382
+ entry = rss_entry(<<~XML)
383
+ <media:content url="https://example.com/video" duration="unknown"/>
384
+ <itunes:duration>42</itunes:duration>
385
+ XML
386
+ assert_nil entry.attachments.first[:duration_in_seconds]
387
+ assert_equal "unknown", entry.attachments.first[:raw][:attributes]["duration"]
388
+ assert_equal :invalid_number, entry.issues.first[:code]
389
+ end
390
+
391
+ private
392
+
393
+ def fixture(format)
394
+ SimpleRSS.parse(File.read(File.join(__dir__, "../data/normalized_#{format}.xml")))
395
+ end
396
+
397
+ def atom_entry(content, options = {})
398
+ SimpleRSS.parse('<feed xmlns="http://www.w3.org/2005/Atom"><title>Example</title><entry>' + content + "</entry></feed>", options).normalized_entries.first
399
+ end
400
+
401
+ def rss_entry(content)
402
+ source = <<~XML
403
+ <rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"
404
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
405
+ <channel><title>Example</title><item>#{content}</item></channel>
406
+ </rss>
407
+ XML
408
+ SimpleRSS.parse(source).normalized_entries.first
409
+ end
410
+ end
@@ -0,0 +1,132 @@
1
+ require "test_helper"
2
+ require "socket"
3
+ require "stringio"
4
+ require "json"
5
+
6
+ class NormalizedFetchTest < Test::Unit::TestCase
7
+ def test_fetch_uses_the_final_response_url_after_relative_redirects
8
+ body = '<feed xmlns="http://www.w3.org/2005/Atom"><title>Example</title><entry><link href="article"/>' \
9
+ '<content type="text/plain" src="full.txt"/></entry></feed>'
10
+ responses = [[302, { "Location" => "../feeds/final.xml" }, ""], [200, { "Content-Type" => "application/atom+xml" }, body]]
11
+ with_server(responses) do |base_url, requests|
12
+ feed = SimpleRSS.fetch("#{base_url}/start/feed.xml", timeout: 1)
13
+ entry = feed.normalized_entries.first
14
+
15
+ assert_equal "#{base_url}/feeds/final.xml", feed.source_url
16
+ assert_equal "#{base_url}/feeds/article", entry.url
17
+ assert_equal "#{base_url}/feeds/full.txt", entry.content_url
18
+ assert_nil entry.content_text
19
+ assert_equal ["GET /start/feed.xml HTTP/1.1", "GET /feeds/final.xml HTTP/1.1"], requests.map(&:first)
20
+ end
21
+ end
22
+
23
+ def test_fetch_retains_conditional_get_and_parse_options
24
+ body = '<rss version="2.0"><channel><title>Example</title><item><link>article</link><category>ruby</category>' \
25
+ "<category>feeds</category></item></channel></rss>"
26
+ headers = { "ETag" => '"version-1"', "Last-Modified" => "Sat, 12 Sep 2026 10:00:00 GMT" }
27
+ with_server([[200, headers, body], [304, headers, ""]]) do |base_url, requests|
28
+ feed = SimpleRSS.fetch("#{base_url}/feed.xml", timeout: 1, array_tags: [:category])
29
+
30
+ assert_equal '"version-1"', feed.etag
31
+ assert_equal headers["Last-Modified"], feed.last_modified
32
+ assert_equal %w[ruby feeds], feed.first.category
33
+ assert_equal "#{base_url}/article", feed.normalized_entries.first.url
34
+ assert_nil SimpleRSS.fetch("#{base_url}/feed.xml", timeout: 1, etag: feed.etag, last_modified: feed.last_modified)
35
+ assert_include requests.last, 'If-None-Match: "version-1"'
36
+ assert_include requests.last, "If-Modified-Since: #{feed.last_modified}"
37
+ end
38
+ end
39
+
40
+ def test_readable_io_and_explicit_source_url_work_without_fetching
41
+ source = StringIO.new('<rss version="2.0"><channel><title>Example</title><item><link>article</link></item></channel></rss>')
42
+ feed = SimpleRSS.parse(source, source_url: "https://example.com/feed.xml")
43
+
44
+ assert_equal "https://example.com/article", feed.normalized_entries.first.url
45
+ assert_nil feed.etag
46
+ end
47
+
48
+ def test_fetched_source_url_wins_over_shared_parse_options_without_mutating_them
49
+ body = '<rss version="2.0"><channel><title>Example</title><item><link>article</link></item></channel></rss>'
50
+ responses = [[302, { "Location" => "/feeds/final.xml" }, ""], [200, {}, body]] * 2
51
+ with_server(responses) do |base_url, requests|
52
+ [nil, "https://wrong.example.com/old.xml"].each do |source_url|
53
+ options = { source_url: source_url, timeout: 1 }
54
+ feed = SimpleRSS.fetch("#{base_url}/initial.xml", options)
55
+
56
+ assert_equal "#{base_url}/feeds/final.xml", feed.source_url
57
+ assert_equal "#{base_url}/feeds/article", feed.normalized_entries.first.url
58
+ assert_equal source_url, options[:source_url]
59
+ assert_equal "https://override.example.com/article", feed.normalized_entries(source_url: "https://override.example.com/feed.xml").first.url
60
+ end
61
+ assert_equal 4, requests.size
62
+ end
63
+ end
64
+
65
+ def test_json_fetch_detects_the_body_across_content_types_and_preserves_conditional_get
66
+ %w[application/feed+json application/json text/plain application/rss+xml].each do |content_type|
67
+ body = JSON.generate(version: "https://jsonfeed.org/version/1.1", title: "Example",
68
+ next_url: "/older.json", items: [{ id: "1", url: "article", content_text: "Hello" }])
69
+ headers = { "Content-Type" => content_type, "ETag" => '"json-1"', "Last-Modified" => "Sat, 12 Sep 2026 10:00:00 GMT" }
70
+ with_server([[200, headers, body], [304, headers, ""]]) do |base_url, requests|
71
+ feed = SimpleRSS.fetch("#{base_url}/feed.json", timeout: 1)
72
+ assert_equal :json_feed, feed.feed_type
73
+ assert_equal "#{base_url}/article", feed.normalized_entries.first.url
74
+ assert_equal "/older.json", feed.next_url
75
+ assert_equal '"json-1"', feed.etag
76
+ assert_equal headers["Last-Modified"], feed.last_modified
77
+ assert_equal 1, requests.size
78
+ assert_include requests.first.find { |header| header.start_with?("Accept:") }, "application/feed+json"
79
+ assert_nil SimpleRSS.fetch("#{base_url}/feed.json", timeout: 1, etag: feed.etag, last_modified: feed.last_modified)
80
+ assert_include requests.last, 'If-None-Match: "json-1"'
81
+ assert_include requests.last, "If-Modified-Since: #{headers["Last-Modified"]}"
82
+ assert_equal 2, requests.size
83
+ end
84
+ end
85
+ end
86
+
87
+ def test_json_fetch_handles_redirects_and_custom_accept_without_fetching_linked_resources
88
+ body = JSON.generate(version: "https://jsonfeed.org/version/1", title: "Example", next_url: "/older.json",
89
+ items: [{ id: "1", url: "article", content_text: "Hello",
90
+ attachments: [{ url: "episode.mp3", mime_type: "audio/mpeg" }] }])
91
+ responses = [[302, { "Location" => "../feeds/final.json" }, ""], [200, {}, body]]
92
+ with_server(responses) do |base_url, requests|
93
+ feed = SimpleRSS.fetch("#{base_url}/start/feed", timeout: 1, headers: { "Accept" => "application/json" })
94
+ entry = feed.normalized_entries.first
95
+ assert_equal "#{base_url}/feeds/final.json", feed.source_url
96
+ assert_equal "#{base_url}/feeds/article", entry.url
97
+ assert_equal "#{base_url}/feeds/episode.mp3", entry.attachments.first[:url]
98
+ assert_equal "/older.json", feed.next_url
99
+ assert_equal ["GET /start/feed HTTP/1.1", "GET /feeds/final.json HTTP/1.1"], requests.map(&:first)
100
+ requests.each { |request| assert_include request, "Accept: application/json" }
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def with_server(responses)
107
+ server = TCPServer.new("127.0.0.1", 0)
108
+ base_url = "http://127.0.0.1:#{server.addr[1]}"
109
+ requests = []
110
+ worker = Thread.new do
111
+ responses.each do |status, headers, body|
112
+ client = server.accept
113
+ request = []
114
+ while (line = client.gets)
115
+ break if line == "\r\n"
116
+
117
+ request << line.strip
118
+ end
119
+ requests << request
120
+ response_headers = headers.merge("Content-Length" => body.bytesize.to_s, "Connection" => "close")
121
+ client.write("HTTP/1.1 #{status} Test\r\n" + response_headers.map { |name, value| "#{name}: #{value}\r\n" }.join + "\r\n" + body)
122
+ client.close
123
+ end
124
+ end
125
+ yield base_url, requests
126
+ worker.value
127
+ ensure
128
+ worker&.kill
129
+ worker&.join
130
+ server&.close
131
+ end
132
+ end