simple-rss 2.1.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 (46) 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 +437 -71
  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/feed_merging_and_diffing_test.rb +140 -0
  26. data/test/base/feedbag_integration_test.rb +21 -0
  27. data/test/base/fetch_integration_test.rb +25 -0
  28. data/test/base/fetch_test.rb +0 -27
  29. data/test/base/filtering_and_validation_test.rb +187 -0
  30. data/test/base/json_feed_test.rb +372 -0
  31. data/test/base/media_and_enclosure_helpers_test.rb +84 -0
  32. data/test/base/normalized_entries_test.rb +410 -0
  33. data/test/base/normalized_fetch_test.rb +132 -0
  34. data/test/base/relation_links_test.rb +162 -0
  35. data/test/data/atom_categories.xml +23 -0
  36. data/test/data/atom_nested_link.xml +13 -0
  37. data/test/data/discovery.html +28 -0
  38. data/test/data/json_feed_1.json +75 -0
  39. data/test/data/json_feed_1_1.json +78 -0
  40. data/test/data/mixed_dates.xml +55 -0
  41. data/test/data/normalized_atom.xml +22 -0
  42. data/test/data/normalized_rss.xml +21 -0
  43. data/test/data/rss_categories.xml +15 -0
  44. data/test/support/http_server.rb +44 -0
  45. data/test/support/replace_method.rb +14 -0
  46. metadata +46 -10
@@ -0,0 +1,187 @@
1
+ require "test_helper"
2
+
3
+ class FilteringAndValidationTest < Test::Unit::TestCase
4
+ def setup
5
+ @rss09 = SimpleRSS.parse open(File.dirname(__FILE__) + "/../data/rss09.rdf")
6
+ @rss20 = SimpleRSS.parse open(File.dirname(__FILE__) + "/../data/rss20.xml")
7
+ @atom = SimpleRSS.parse open(File.dirname(__FILE__) + "/../data/atom.xml")
8
+ end
9
+
10
+ def test_feed_type_for_known_formats
11
+ assert_equal :rss1, @rss09.feed_type
12
+ assert_equal :rss2, @rss20.feed_type
13
+ assert_equal :atom, @atom.feed_type
14
+ end
15
+
16
+ def test_feed_type_unknown_for_non_standard_feed
17
+ feed = SimpleRSS.parse <<~XML
18
+ <?xml version="1.0" encoding="UTF-8"?>
19
+ <feed>
20
+ <title>Unknown Feed</title>
21
+ <entry>
22
+ <title>Post</title>
23
+ </entry>
24
+ </feed>
25
+ XML
26
+
27
+ assert_equal :unknown, feed.feed_type
28
+ end
29
+
30
+ def test_class_valid_returns_true_for_well_formed_feed
31
+ xml = <<~XML
32
+ <?xml version="1.0" encoding="UTF-8"?>
33
+ <rss version="2.0">
34
+ <channel>
35
+ <title>Valid Feed</title>
36
+ <link>http://example.com</link>
37
+ <item>
38
+ <title>Post</title>
39
+ </item>
40
+ </channel>
41
+ </rss>
42
+ XML
43
+
44
+ assert_equal true, SimpleRSS.valid?(xml)
45
+ end
46
+
47
+ def test_class_valid_returns_false_for_invalid_feed
48
+ invalid_xml = open(File.dirname(__FILE__) + "/../data/not-rss.xml").read
49
+
50
+ assert_equal false, SimpleRSS.valid?(invalid_xml)
51
+ end
52
+
53
+ def test_class_valid_returns_false_when_source_read_fails
54
+ unreadable_source = Object.new
55
+ unreadable_source.define_singleton_method(:read) do
56
+ raise IOError, "stream closed"
57
+ end
58
+
59
+ assert_equal false, SimpleRSS.valid?(unreadable_source)
60
+ end
61
+
62
+ def test_instance_valid_requires_metadata_and_items
63
+ valid_feed = SimpleRSS.parse <<~XML
64
+ <?xml version="1.0" encoding="UTF-8"?>
65
+ <rss version="2.0">
66
+ <channel>
67
+ <title>Valid Feed</title>
68
+ <item>
69
+ <title>Post</title>
70
+ </item>
71
+ </channel>
72
+ </rss>
73
+ XML
74
+
75
+ invalid_feed = SimpleRSS.parse <<~XML
76
+ <?xml version="1.0" encoding="UTF-8"?>
77
+ <rss version="2.0">
78
+ <channel>
79
+ <description>No title and no link</description>
80
+ <item>
81
+ <description>Body only</description>
82
+ </item>
83
+ </channel>
84
+ </rss>
85
+ XML
86
+
87
+ empty_feed = SimpleRSS.parse <<~XML
88
+ <?xml version="1.0" encoding="UTF-8"?>
89
+ <rss version="2.0">
90
+ <channel>
91
+ <title>No Items</title>
92
+ </channel>
93
+ </rss>
94
+ XML
95
+
96
+ assert_equal true, valid_feed.valid?
97
+ assert_equal false, invalid_feed.valid?
98
+ assert_equal false, empty_feed.valid?
99
+ end
100
+
101
+ def test_items_since_filters_by_date
102
+ threshold = Time.parse("Wed Aug 24 13:30:00 UTC 2005")
103
+
104
+ filtered = @rss20.items_since(threshold)
105
+
106
+ assert_equal 1, filtered.size
107
+ assert_operator filtered.first[:pubDate], :>, threshold
108
+ end
109
+
110
+ def test_items_by_category_matches_strings_and_arrays
111
+ feed_with_string_category = SimpleRSS.parse <<~XML
112
+ <?xml version="1.0" encoding="UTF-8"?>
113
+ <rss version="2.0">
114
+ <channel>
115
+ <title>String Category Feed</title>
116
+ <item>
117
+ <title>Ruby News</title>
118
+ <category>Technology</category>
119
+ </item>
120
+ <item>
121
+ <title>Sports News</title>
122
+ <category>Sports</category>
123
+ </item>
124
+ </channel>
125
+ </rss>
126
+ XML
127
+
128
+ feed_with_array_category = SimpleRSS.parse(
129
+ <<~XML,
130
+ <?xml version="1.0" encoding="UTF-8"?>
131
+ <rss version="2.0">
132
+ <channel>
133
+ <title>Array Category Feed</title>
134
+ <item>
135
+ <title>Dev Update</title>
136
+ <category>Technology</category>
137
+ <category>Ruby</category>
138
+ </item>
139
+ </channel>
140
+ </rss>
141
+ XML
142
+ array_tags: [:category]
143
+ )
144
+
145
+ string_results = feed_with_string_category.items_by_category("tech")
146
+ array_results = feed_with_array_category.items_by_category("ruby")
147
+
148
+ assert_equal 1, string_results.size
149
+ assert_equal "Ruby News", string_results.first[:title]
150
+ assert_equal 1, array_results.size
151
+ assert_equal "Dev Update", array_results.first[:title]
152
+ end
153
+
154
+ def test_search_matches_title_description_summary_and_content
155
+ feed = SimpleRSS.parse <<~XML
156
+ <?xml version="1.0" encoding="UTF-8"?>
157
+ <rss version="2.0">
158
+ <channel>
159
+ <title>Search Feed</title>
160
+ <item>
161
+ <title>Ruby Patterns</title>
162
+ <description>Language design</description>
163
+ </item>
164
+ <item>
165
+ <title>Other Topic</title>
166
+ <description>Talks about BREAKING updates</description>
167
+ </item>
168
+ <item>
169
+ <title>Third Topic</title>
170
+ <summary>A quick ruby summary</summary>
171
+ </item>
172
+ <item>
173
+ <title>Fourth Topic</title>
174
+ <content>Deep dive into Ruby internals</content>
175
+ </item>
176
+ </channel>
177
+ </rss>
178
+ XML
179
+
180
+ ruby_results = feed.search("ruby")
181
+ breaking_results = feed.search("breaking")
182
+
183
+ assert_equal 3, ruby_results.size
184
+ assert_equal 1, breaking_results.size
185
+ assert_equal "Other Topic", breaking_results.first[:title]
186
+ end
187
+ end
@@ -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
@@ -0,0 +1,84 @@
1
+ require "test_helper"
2
+
3
+ class MediaAndEnclosureHelpersTest < Test::Unit::TestCase
4
+ def setup
5
+ @feed = SimpleRSS.parse <<~XML
6
+ <?xml version="1.0" encoding="UTF-8"?>
7
+ <rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
8
+ <channel>
9
+ <title>Podcast Feed</title>
10
+ <item>
11
+ <title>Episode 1</title>
12
+ <enclosure url="https://example.com/audio-1.mp3" type="audio/mpeg" length="12345" />
13
+ <media:content url="https://example.com/image-1.jpg" type="image/jpeg" />
14
+ <media:description>Episode image</media:description>
15
+ <media:thumbnail url="https://example.com/thumb-1.jpg" />
16
+ <itunes:duration>00:42:00</itunes:duration>
17
+ <itunes:image href="https://example.com/itunes-1.jpg" />
18
+ </item>
19
+ <item>
20
+ <title>Episode 2</title>
21
+ <media:thumbnail url="https://example.com/thumb-2.jpg" />
22
+ </item>
23
+ <item>
24
+ <title>Episode 3</title>
25
+ <enclosure url="https://example.com/audio-3.mp3" type="audio/mpeg" length="999" />
26
+ </item>
27
+ <item>
28
+ <title>Episode 4</title>
29
+ <enclosure url="" type="audio/mpeg" length="111" />
30
+ <media:thumbnail url="" />
31
+ <itunes:image href="" />
32
+ </item>
33
+ </channel>
34
+ </rss>
35
+ XML
36
+ end
37
+
38
+ def test_enclosures_extracts_podcast_enclosures
39
+ enclosures = @feed.enclosures
40
+
41
+ assert_equal 2, enclosures.size
42
+ assert_equal "https://example.com/audio-1.mp3", enclosures.first[:url]
43
+ assert_equal "audio/mpeg", enclosures.first[:type]
44
+ assert_equal "12345", enclosures.first[:length]
45
+ assert_equal "Episode 1", enclosures.first[:item][:title]
46
+ end
47
+
48
+ def test_images_collects_unique_media_urls
49
+ images = @feed.images
50
+
51
+ assert_equal [
52
+ "https://example.com/thumb-1.jpg",
53
+ "https://example.com/image-1.jpg",
54
+ "https://example.com/itunes-1.jpg",
55
+ "https://example.com/thumb-2.jpg"
56
+ ], images
57
+ end
58
+
59
+ def test_item_media_helpers
60
+ first_item = @feed.items.first
61
+ second_item = @feed.items[1]
62
+ third_item = @feed.items[2]
63
+ fourth_item = @feed.items.last
64
+
65
+ assert_equal true, first_item.has_media?
66
+ assert_equal "https://example.com/image-1.jpg", first_item.media_url
67
+
68
+ assert_equal true, second_item.has_media?
69
+ assert_equal "https://example.com/thumb-2.jpg", second_item.media_url
70
+
71
+ assert_equal true, third_item.has_media?
72
+ assert_equal "https://example.com/audio-3.mp3", third_item.media_url
73
+
74
+ assert_equal false, fourth_item.has_media?
75
+ assert_nil fourth_item.media_url
76
+ end
77
+
78
+ def test_media_description_and_itunes_duration_are_parsed
79
+ first_item = @feed.items.first
80
+
81
+ assert_equal "Episode image", first_item[:media_description]
82
+ assert_equal "00:42:00", first_item[:itunes_duration]
83
+ end
84
+ end