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,217 @@
1
+ require "test_helper"
2
+
3
+ class CategoryParsingTest < Test::Unit::TestCase
4
+ def test_atom_terms_are_collected_and_filterable
5
+ source = <<~XML
6
+ <feed xmlns="http://www.w3.org/2005/Atom">
7
+ <title>Categories</title>
8
+ <entry>
9
+ <title>Ruby news</title>
10
+ <category term="ruby" label="Ruby Language"/>
11
+ <category term="rails" label="Rails"/>
12
+ </entry>
13
+ </feed>
14
+ XML
15
+ feed = SimpleRSS.parse(source, array_tags: [:category])
16
+
17
+ assert_equal %w[ruby rails], feed.first.category
18
+ assert_equal [feed.first], feed.items_by_category("ruby")
19
+ assert_equal "ruby", SimpleRSS.parse(source).first.category
20
+ end
21
+
22
+ def test_atom_attribute_variants_preserve_order_duplicates_and_source
23
+ source = File.read(File.join(__dir__, "../data/atom_categories.xml"))
24
+ feed = SimpleRSS.parse(source, array_tags: [:category])
25
+
26
+ assert_equal ["ruby", "rails", "Ruby & RSS", "ruby"], feed.first.category
27
+ assert_equal [feed.first], feed.items_by_category("RAILS")
28
+ assert_equal [], feed.items_by_category("Human label")
29
+ assert_nil feed[1].category
30
+ assert_equal ["sports"], feed[2].category
31
+ assert_equal source, feed.source
32
+ assert_equal "ruby", SimpleRSS.parse(source).first.category
33
+ end
34
+
35
+ def test_atom_scalar_mode_skips_missing_and_blank_terms
36
+ feed = parse_entry(<<~XML)
37
+ <category label="Label only">Not a term</category>
38
+ <category term=" &#32; "/>
39
+ <category term="first"/>
40
+ <category term="second"/>
41
+ XML
42
+
43
+ assert_equal "first", feed.first.category
44
+ assert_equal [feed.first], feed.items_by_category("first")
45
+ assert_equal [], feed.items_by_category("second")
46
+ end
47
+
48
+ def test_categories_exclude_nested_metadata_content_comments_and_cdata
49
+ feed = parse_entry(<<~XML, array_tags: [:category])
50
+ <source xml:base="https://example.com/"><category term="source"/></source>
51
+ <content type="xhtml"><div><category term="content"/></div></content>
52
+ <extension><category term="extension"/></extension>
53
+ <!-- <category term="comment"/> -->
54
+ <![CDATA[<category term="cdata"/>]]>
55
+ <?example <category term="instruction"/> ?>
56
+ <category term="direct"><category term="nested"/></category>
57
+ XML
58
+
59
+ assert_equal ["direct"], feed.first.category
60
+ end
61
+
62
+ def test_categories_do_not_borrow_terms_from_neighboring_elements_or_entries
63
+ source = <<~XML
64
+ <feed xmlns="http://www.w3.org/2005/Atom">
65
+ <title>Boundaries</title>
66
+ <category term="feed"/>
67
+ <entry>
68
+ <title>First</title>
69
+ <category label="Missing"><extension term="nested"/></category>
70
+ <extension term="neighbor"/>
71
+ </entry>
72
+ <entry><title>Second</title><category term="second"/></entry>
73
+ </feed>
74
+ XML
75
+
76
+ [SimpleRSS.parse(source), SimpleRSS.parse(source, array_tags: [:category])].each do |feed|
77
+ assert_nil feed.first.category
78
+ assert_equal [], feed.items_by_category("neighbor")
79
+ assert_equal [feed[1]], feed.items_by_category("second")
80
+ end
81
+ end
82
+
83
+ def test_namespace_prefixes_resolve_at_feed_entry_and_category_scope
84
+ source = <<~XML
85
+ <feed xmlns:atom="http://www.w3.org/2005/Atom" xmlns:Topic="http://www.w3.org/2005/Atom">
86
+ <title>Namespace scopes</title>
87
+ <atom:entry xmlns:local="http://www.w3.org/2005/Atom">
88
+ <title>First</title>
89
+ <Topic:category term="feed-scope"/>
90
+ <local:category term="entry-scope"/>
91
+ <inline:category xmlns:inline="http://www.w3.org/2005/At&#111;m" term="category-scope"/>
92
+ <category xmlns="http://www.w3.org/2005/Atom" term="local-default"/>
93
+ <category term="unqualified">Not Atom</category>
94
+ </atom:entry>
95
+ </feed>
96
+ XML
97
+ feed = SimpleRSS.parse(source, array_tags: [:category])
98
+
99
+ assert_equal %w[feed-scope entry-scope category-scope local-default], feed.first.category
100
+ end
101
+
102
+ def test_namespace_rebinding_and_default_resets_do_not_leak_categories
103
+ source = <<~XML
104
+ <feed xmlns="http://www.w3.org/2005/Atom" xmlns:topic="http://www.w3.org/2005/Atom">
105
+ <title>Rebinding</title>
106
+ <entry xmlns:topic="urn:other">
107
+ <title>First</title>
108
+ <topic:category term="foreign"/>
109
+ <category xmlns="urn:other" term="other">Foreign text</category>
110
+ <category xmlns="" term="reset">Unqualified text</category>
111
+ <topic:category xmlns:topic="http://www.w3.org/2005/Atom" term="restored"/>
112
+ </entry>
113
+ <entry><title>Second</title><topic:category term="inherited"/></entry>
114
+ </feed>
115
+ XML
116
+ feed = SimpleRSS.parse(source, array_tags: [:category])
117
+
118
+ assert_equal ["restored"], feed.first.category
119
+ assert_equal ["inherited"], feed[1].category
120
+ end
121
+
122
+ def test_exact_element_and_attribute_names_are_required
123
+ feed = parse_entry(<<~XML, array_tags: [:category])
124
+ <categoryish term="wrong-element"/>
125
+ <category data-term="wrong-attribute"/>
126
+ <category label="term='quoted'"/>
127
+ <category xmlns:other="urn:other" other:term="wrong-namespace"/>
128
+ <unknown:category term="undeclared"/>
129
+ <category term="correct"/>
130
+ XML
131
+
132
+ assert_equal ["correct"], feed.first.category
133
+ end
134
+
135
+ def test_rss_text_cdata_scalar_and_array_filters_remain_compatible
136
+ source = File.read(File.join(__dir__, "../data/rss_categories.xml"))
137
+ scalar_feed = SimpleRSS.parse(source)
138
+ array_feed = SimpleRSS.parse(source, array_tags: [:category])
139
+
140
+ assert_equal "Technology", scalar_feed.first.category
141
+ assert_equal ["Technology", "Ruby & RSS", "Technology"], array_feed.first.category
142
+ assert_equal [scalar_feed.first], scalar_feed.items_by_category("tech")
143
+ assert_equal [array_feed.first], array_feed.items_by_category("ruby")
144
+ assert_equal ["Sports"], array_feed[1].category
145
+ end
146
+
147
+ def test_rss_categories_exclude_embedded_markup_and_accept_atom_extensions
148
+ source = <<~XML
149
+ <rss version="2.0" xmlns:topic="http://www.w3.org/2005/Atom">
150
+ <channel>
151
+ <title>Mixed categories</title>
152
+ <item>
153
+ <title>First</title>
154
+ <description><![CDATA[<category>Embedded</category>]]></description>
155
+ <extension><category>Nested</category></extension>
156
+ <category term="ignored">RSS text</category>
157
+ <topic:category term="Atom term"/>
158
+ <category xmlns="urn:other">Foreign text</category>
159
+ </item>
160
+ </channel>
161
+ </rss>
162
+ XML
163
+ feed = SimpleRSS.parse(source, array_tags: [:category])
164
+
165
+ assert_equal ["RSS text", "Atom term"], feed.first.category
166
+ end
167
+
168
+ def test_rss_namespaces_and_empty_category_behavior_are_preserved
169
+ source = <<~XML
170
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
171
+ <channel><title>RSS 1.0</title></channel>
172
+ <item><title>First</title><category>Ruby</category><category></category></item>
173
+ <item><title>Second</title><category/></item>
174
+ </rdf:RDF>
175
+ XML
176
+ feed = SimpleRSS.parse(source, array_tags: [:category])
177
+
178
+ assert_equal ["Ruby", ""], feed.first.category
179
+ assert_nil feed[1].category
180
+ assert_equal "", SimpleRSS.parse(source)[1].category
181
+ end
182
+
183
+ def test_namespace_declarations_in_comments_do_not_override_the_feed
184
+ source = <<~XML
185
+ <!-- <feed xmlns="urn:other"> -->
186
+ <feed xmlns="http://www.w3.org/2005/Atom">
187
+ <title>Actual feed</title>
188
+ <entry><title>First</title><category term="actual"/></entry>
189
+ </feed>
190
+ XML
191
+
192
+ assert_equal "actual", SimpleRSS.parse(source).first.category
193
+ end
194
+
195
+ def test_rss_channel_namespaces_do_not_apply_to_sibling_rdf_items
196
+ source = <<~XML
197
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
198
+ <channel xmlns:topic="http://www.w3.org/2005/Atom"><title>RSS</title></channel>
199
+ <item><title>First</title><topic:category term="out-of-scope"/><category>RSS text</category></item>
200
+ </rdf:RDF>
201
+ XML
202
+
203
+ assert_equal ["RSS text"], SimpleRSS.parse(source, array_tags: [:category]).first.category
204
+ end
205
+
206
+ private
207
+
208
+ def parse_entry(content, options = {})
209
+ source = <<~XML
210
+ <feed xmlns="http://www.w3.org/2005/Atom">
211
+ <title>Categories</title>
212
+ <entry><title>Entry</title>#{content}</entry>
213
+ </feed>
214
+ XML
215
+ SimpleRSS.parse(source, options)
216
+ end
217
+ end
@@ -0,0 +1,95 @@
1
+ require "test_helper"
2
+
3
+ class DateOrderingTest < Test::Unit::TestCase
4
+ def setup
5
+ @feed = SimpleRSS.parse(File.read(File.join(__dir__, "../data/mixed_dates.xml")))
6
+ end
7
+
8
+ def test_latest_uses_valid_fallbacks_and_preserves_equal_date_order
9
+ assert_equal [
10
+ "Updated first", "Updated second", "Fractional", "Published", "Publication",
11
+ "Historical", "Missing first", "Blank", "Invalid"
12
+ ], @feed.latest.map(&:title)
13
+ end
14
+
15
+ def test_latest_limits_results_without_changing_original_entries
16
+ original = @feed.to_hash
17
+
18
+ assert_equal ["Updated first", "Updated second", "Fractional"], @feed.latest(3).map(&:title)
19
+ assert_equal [], @feed.latest(0)
20
+ assert_same @feed.items[3], @feed.latest.first
21
+ assert_equal original, @feed.to_hash
22
+ end
23
+
24
+ def test_latest_sorts_atom_entries_with_only_published_dates
25
+ feed = SimpleRSS.parse <<~XML
26
+ <feed xmlns="http://www.w3.org/2005/Atom">
27
+ <title>Publication dates</title>
28
+ <entry><title>Newer</title><published>2026-09-03T00:00:00Z</published></entry>
29
+ <entry><title>Older</title><published>2026-09-01T00:00:00Z</published></entry>
30
+ </feed>
31
+ XML
32
+
33
+ assert_equal %w[Newer Older], feed.latest.map(&:title)
34
+ end
35
+
36
+ def test_items_since_uses_valid_fallbacks_and_excludes_undated_entries
37
+ assert_equal ["Fractional", "Updated first", "Published", "Updated second"],
38
+ @feed.items_since(Time.iso8601("2026-09-01T00:00:00Z")).map(&:title)
39
+ assert_equal ["Fractional", "Updated first", "Updated second"],
40
+ @feed.items_since(Time.iso8601("2026-09-02T00:00:00Z")).map(&:title)
41
+ assert_equal [], @feed.items_since(Time.iso8601("2026-09-04T00:00:00Z"))
42
+ end
43
+
44
+ def test_merge_sorts_dated_entries_before_undated_identified_entries
45
+ assert_equal [
46
+ "Updated first", "Updated second", "Fractional", "Published", "Publication",
47
+ "Historical", "Blank", "Invalid", "Missing first"
48
+ ], @feed.merge.map(&:title)
49
+ end
50
+
51
+ def test_merge_uses_fallback_dates_to_dedupe_and_keeps_unidentified_entries_last
52
+ other_feed = SimpleRSS.parse <<~XML
53
+ <rss version="2.0">
54
+ <channel>
55
+ <title>Other feed</title>
56
+ <item>
57
+ <guid>updated-first</guid>
58
+ <title>Replacement</title>
59
+ <pubDate>not-a-date</pubDate>
60
+ <published>2026-09-04T00:00:00Z</published>
61
+ </item>
62
+ <item><title>Unidentified newest</title><pubDate>2030-01-01T00:00:00Z</pubDate></item>
63
+ </channel>
64
+ </rss>
65
+ XML
66
+ original = @feed.to_hash
67
+ other_original = other_feed.to_hash
68
+
69
+ assert_equal [
70
+ "Replacement", "Updated second", "Fractional", "Published", "Publication",
71
+ "Historical", "Blank", "Invalid", "Missing first", "Unidentified newest"
72
+ ], SimpleRSS.merge(@feed, other_feed).map(&:title)
73
+ assert_equal original, @feed.to_hash
74
+ assert_equal other_original, other_feed.to_hash
75
+ end
76
+
77
+ def test_merge_keeps_the_first_duplicate_when_dates_are_equal
78
+ other_feed = SimpleRSS.parse <<~XML
79
+ <rss version="2.0">
80
+ <channel>
81
+ <title>Other feed</title>
82
+ <item>
83
+ <guid>updated-second</guid>
84
+ <title>Later duplicate</title>
85
+ <updated>2026-09-03T00:00:00.000000002Z</updated>
86
+ </item>
87
+ </channel>
88
+ </rss>
89
+ XML
90
+
91
+ duplicates = @feed.merge(other_feed).select { |item| item[:guid] == "updated-second" }
92
+
93
+ assert_equal ["Updated second"], duplicates.map(&:title)
94
+ end
95
+ end
@@ -0,0 +1,29 @@
1
+ require "test_helper"
2
+ require "open3"
3
+
4
+ class DiscoveryDependencyTest < Test::Unit::TestCase
5
+ def test_core_parsing_works_without_loading_nokogiri_and_discovery_explains_the_dependency
6
+ script = <<~RUBY_SCRIPT
7
+ require "simple-rss"
8
+ abort "Nokogiri loaded by core" if defined?(Nokogiri)
9
+ Kernel.prepend(Module.new do
10
+ def require(path)
11
+ raise LoadError, "optional dependency is unavailable" if path == "nokogiri"
12
+
13
+ super
14
+ end
15
+ end)
16
+ feed = SimpleRSS.parse('<rss version="2.0"><channel><title>Example</title></channel></rss>')
17
+ abort "Core parsing failed" unless feed.title == "Example"
18
+ begin
19
+ SimpleRSS.discover("http://127.0.0.1/", timeout: 0.1)
20
+ abort "Expected a dependency error"
21
+ rescue SimpleRSS::DiscoveryDependencyError => error
22
+ puts error.message
23
+ end
24
+ RUBY_SCRIPT
25
+ output, status = Open3.capture2e(RbConfig.ruby, "-Ilib", "-e", script)
26
+ assert status.success?, output
27
+ assert_include output, 'Install the optional "nokogiri" gem'
28
+ end
29
+ end
@@ -0,0 +1,151 @@
1
+ require "test_helper"
2
+ require_relative "../support/http_server"
3
+
4
+ class DiscoveryTest < Test::Unit::TestCase
5
+ include HTTPServer
6
+
7
+ def test_discovers_advertised_feeds_without_fetching_them
8
+ html = '<html><head><link rel="alternate" type="application/rss+xml" title="News" href="/feed.xml"></head></html>'
9
+ with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
10
+ candidates = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
11
+ assert_equal [{ url: "#{url}/feed.xml", title: "News", format: :rss, media_type: "application/rss+xml", source: :html_link, verified: false }], candidates
12
+ assert_equal 1, requests.size
13
+ end
14
+ end
15
+
16
+ def test_acceptance_corpus_preserves_formats_order_titles_and_distinct_queries
17
+ html = File.read(File.join(__dir__, "../data/discovery.html"))
18
+ with_server([[200, { "Content-Type" => "text/html; charset=utf-8" }, html]]) do |url, requests|
19
+ candidates = SimpleRSS.discover("#{url}/blog/index.html", network_policy: :unrestricted, timeout: 1)
20
+ assert_equal(["news.xml?edition=1&lang=en", "atom.xml", "feed.json", "legacy.json", "feed.rdf", "news.xml?edition=2&lang=en"], candidates.map { |candidate| candidate[:url].delete_prefix("#{url}/syndication/") })
21
+ assert_equal(%i[rss atom json_feed json_feed rss rss], candidates.map { |candidate| candidate[:format] })
22
+ assert_equal "News & updates", candidates.first[:title]
23
+ assert_equal "application/rss+xml", candidates.first[:media_type]
24
+ assert_equal [false], candidates.map { |candidate| candidate[:verified] }.uniq
25
+ assert_equal [:html_link], candidates.map { |candidate| candidate[:source] }.uniq
26
+ assert_equal 1, requests.size
27
+ end
28
+ end
29
+
30
+ def test_direct_empty_feeds_are_verified_without_instance_valid
31
+ sources = {
32
+ rss: '<rss version="2.0"><channel><title>RSS</title></channel></rss>',
33
+ atom: '<feed xmlns="http://www.w3.org/2005/Atom"><title>Atom</title></feed>',
34
+ json_feed: '{"version":"https://jsonfeed.org/version/1.1","title":"JSON Feed","items":[]}'
35
+ }
36
+ sources.each do |format, body|
37
+ with_server([[200, { "Content-Type" => "text/plain" }, body]]) do |url, requests|
38
+ candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
39
+ assert_equal "#{url}/", candidate[:url]
40
+ assert_equal format, candidate[:format]
41
+ assert_equal :document, candidate[:source]
42
+ assert_equal true, candidate[:verified]
43
+ assert_equal 1, requests.size
44
+ end
45
+ end
46
+ end
47
+
48
+ def test_empty_self_closing_feed_containers_are_recognized
49
+ ['<rss version="2.0"><channel/></rss>', '<feed xmlns="http://www.w3.org/2005/Atom"/>'].each do |body|
50
+ with_server([[200, {}, body]]) do |url, _requests|
51
+ candidates = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
52
+ assert_equal true, candidates.first[:verified]
53
+ assert_empty SimpleRSS.parse(body).normalized_entries
54
+ end
55
+ end
56
+ end
57
+
58
+ def test_rdf_feed_and_xhtml_metadata_are_supported
59
+ rdf = '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/"><channel><title>RDF</title></channel></rdf:RDF>'
60
+ with_server([[200, {}, rdf]]) do |url, _requests|
61
+ candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
62
+ assert_equal :rss, candidate[:format]
63
+ assert_equal "RDF", candidate[:title]
64
+ end
65
+ xhtml = '<html xmlns="http://www.w3.org/1999/xhtml"><head><link rel="alternate" type="application/atom+xml" href="feed.xml" /></head><body /></html>'
66
+ with_server([[200, { "Content-Type" => "application/xhtml+xml" }, xhtml]]) do |url, _requests|
67
+ assert_equal(["#{url}/feed.xml"], SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).map { |candidate| candidate[:url] })
68
+ end
69
+ end
70
+
71
+ def test_redirects_base_urls_and_protocol_relative_links_preserve_queries
72
+ html = '<html><head><base href="../feeds/"><link rel=alternate type=application/rss+xml href="news.xml?edition=1#items"><link rel=alternate type=application/atom+xml href="//example.com/atom?q=1#entry"></head></html>'
73
+ responses = [[302, { "Location" => "../pages/index?view=1#head" }, ""], [200, { "Content-Type" => "text/html" }, html]]
74
+ with_server(responses) do |url, requests|
75
+ candidates = SimpleRSS.discover("#{url}/start/page", network_policy: :unrestricted, timeout: 1)
76
+ assert_equal(["#{url}/feeds/news.xml?edition=1", "http://example.com/atom?q=1"], candidates.map { |candidate| candidate[:url] })
77
+ assert_equal ["GET /start/page HTTP/1.1", "GET /pages/index?view=1 HTTP/1.1"], requests.map(&:first)
78
+ end
79
+ end
80
+
81
+ def test_invalid_links_are_ignored_and_invalid_first_base_uses_document_url
82
+ html = '<html><head><base href="javascript:wrong"><base href="https://wrong.example.com/">' \
83
+ '<link rel=alternate type=application/rss+xml href="valid.xml">' \
84
+ '<link rel=alternate type=application/rss+xml href="javascript:wrong">' \
85
+ '<link rel=alternate type=application/rss+xml href="file:///tmp/feed.xml">' \
86
+ '<link rel=alternate type=application/rss+xml href="http://user:password@example.com/feed">' \
87
+ '<link rel=alternate type=application/rss+xml href="http://[broken">' \
88
+ '<link rel=alternate type=application/rss+xml href=" "></head></html>'
89
+ with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
90
+ assert_equal(["#{url}/valid.xml"], SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).map { |candidate| candidate[:url] })
91
+ assert_equal 1, requests.size
92
+ end
93
+ end
94
+
95
+ def test_omitted_head_tags_and_html_entities_are_parsed_as_html
96
+ html = '<!doctype html><title>Example</title><link rel=alternate type=application/rss+xml href="/feed?q=1&amp;b=2" title="Café &amp; ☀"><p>Post</p>'
97
+ with_server([[200, { "Content-Type" => "text/html; charset=utf-8" }, html]]) do |url, _requests|
98
+ candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
99
+ assert_equal "#{url}/feed?q=1&b=2", candidate[:url]
100
+ assert_equal "Café & ☀", candidate[:title]
101
+ end
102
+ end
103
+
104
+ def test_no_feeds_is_distinct_from_http_and_parse_failures
105
+ with_server([[200, { "Content-Type" => "text/html" }, "<p>No feeds here</p>"]]) do |url, _requests|
106
+ assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
107
+ end
108
+ with_server([[404, { "Content-Type" => "text/html" }, "<p>Not found</p>"]]) do |url, _requests|
109
+ error = assert_raise(SimpleRSS::HTTPError) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
110
+ assert_equal 404, error.status_code
111
+ end
112
+ ['{"version":', '{"hello":"world"}', '<rss version="2.0">broken</rss>', "not a feed", ""].each do |body|
113
+ with_server([[200, { "Content-Type" => "application/octet-stream" }, body]]) do |url, _requests|
114
+ assert_raise(SimpleRSS::DiscoveryError, body) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
115
+ end
116
+ end
117
+ end
118
+
119
+ def test_feed_markup_in_html_scripts_does_not_become_a_direct_feed
120
+ html = '<html><head><script type="text/plain"><rss version="2.0"><channel><title>Wrong</title></channel></rss></script></head><body></body></html>'
121
+ with_server([[200, { "Content-Type" => "application/rss+xml" }, html]]) do |url, _requests|
122
+ assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
123
+ end
124
+ end
125
+
126
+ def test_foreign_namespaces_do_not_fabricate_verified_feeds
127
+ ['<feed xmlns="urn:component"/>', '<rss xmlns="urn:component"><channel/></rss>'].each do |body|
128
+ with_server([[200, { "Content-Type" => "text/html" }, body]]) do |url, _requests|
129
+ assert_empty SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1)
130
+ end
131
+ end
132
+ end
133
+
134
+ def test_parser_limits_fail_clearly
135
+ html = "<html><head></head><body>" + ("<div>" * 140)
136
+ with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, _requests|
137
+ assert_raise(SimpleRSS::DiscoveryError) { SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1) }
138
+ end
139
+ end
140
+
141
+ def test_advertised_private_urls_are_only_unverified_metadata
142
+ html = '<html><head><link rel=alternate type=application/rss+xml href="http://127.0.0.1:9/feed"></head></html>'
143
+ with_server([[200, { "Content-Type" => "text/html" }, html]]) do |url, requests|
144
+ candidate = SimpleRSS.discover(url, network_policy: :unrestricted, timeout: 1).first
145
+ assert_equal false, candidate[:verified]
146
+ assert_equal "http://127.0.0.1:9/feed", candidate[:url]
147
+ assert_raise(SimpleRSS::PolicyError) { SimpleRSS.fetch(candidate[:url], network_policy: :public) }
148
+ assert_equal 1, requests.size
149
+ end
150
+ end
151
+ end