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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/README.md +492 -8
- data/Rakefile +1 -1
- data/examples/digest.rb +19 -0
- data/examples/discover.rb +14 -0
- data/examples/feedbag.rb +10 -0
- data/lib/simple-rss/discovery.rb +107 -0
- data/lib/simple-rss/entry_normalizer.rb +356 -0
- data/lib/simple-rss/http_client.rb +216 -0
- data/lib/simple-rss/json_entry_normalizer.rb +147 -0
- data/lib/simple-rss/json_feed.rb +176 -0
- data/lib/simple-rss/normalized_entry.rb +79 -0
- data/lib/simple-rss/request_errors.rb +21 -0
- data/lib/simple-rss/request_policy.rb +72 -0
- data/lib/simple-rss/xml_element.rb +136 -0
- data/lib/simple-rss.rb +225 -77
- data/simple-rss.gemspec +5 -5
- data/test/base/category_parsing_test.rb +217 -0
- data/test/base/date_ordering_test.rb +95 -0
- data/test/base/discovery_dependency_test.rb +29 -0
- data/test/base/discovery_test.rb +151 -0
- data/test/base/discovery_transport_test.rb +413 -0
- data/test/base/enumerable_test.rb +16 -0
- data/test/base/feedbag_integration_test.rb +21 -0
- data/test/base/json_feed_test.rb +372 -0
- data/test/base/normalized_entries_test.rb +410 -0
- data/test/base/normalized_fetch_test.rb +132 -0
- data/test/base/relation_links_test.rb +162 -0
- data/test/data/atom_categories.xml +23 -0
- data/test/data/atom_nested_link.xml +13 -0
- data/test/data/discovery.html +28 -0
- data/test/data/json_feed_1.json +75 -0
- data/test/data/json_feed_1_1.json +78 -0
- data/test/data/mixed_dates.xml +55 -0
- data/test/data/normalized_atom.xml +22 -0
- data/test/data/normalized_rss.xml +21 -0
- data/test/data/rss_categories.xml +15 -0
- data/test/support/http_server.rb +44 -0
- data/test/support/replace_method.rb +14 -0
- 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 & 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>& 😀</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>& 😀</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
|