indexmap 0.8.2 → 0.9.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +287 -35
  4. data/UPGRADE.md +361 -0
  5. data/docs/architecture.md +55 -0
  6. data/docs/deployment.md +223 -0
  7. data/docs/domain-terms.md +27 -0
  8. data/docs/dynamic-sitemaps.md +302 -0
  9. data/docs/search-engine-ping.md +368 -0
  10. data/docs/storage.md +94 -0
  11. data/lib/generators/indexmap/install_generator.rb +1 -1
  12. data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
  13. data/lib/indexmap/artifact.rb +9 -0
  14. data/lib/indexmap/configuration.rb +12 -1
  15. data/lib/indexmap/creator.rb +45 -42
  16. data/lib/indexmap/index_now_configuration.rb +3 -1
  17. data/lib/indexmap/output.rb +19 -5
  18. data/lib/indexmap/parser.rb +112 -52
  19. data/lib/indexmap/pinger/base.rb +6 -5
  20. data/lib/indexmap/pinger/google.rb +12 -8
  21. data/lib/indexmap/pinger/index_now.rb +97 -52
  22. data/lib/indexmap/publication.rb +89 -0
  23. data/lib/indexmap/robots.rb +67 -0
  24. data/lib/indexmap/robots_controller.rb +13 -0
  25. data/lib/indexmap/sitemaps_controller.rb +57 -0
  26. data/lib/indexmap/storage/active_storage.rb +132 -1
  27. data/lib/indexmap/storage/filesystem.rb +182 -11
  28. data/lib/indexmap/storage/memory.rb +1 -1
  29. data/lib/indexmap/task_runner.rb +21 -28
  30. data/lib/indexmap/validator/duplicates.rb +83 -0
  31. data/lib/indexmap/validator.rb +171 -59
  32. data/lib/indexmap/version.rb +1 -1
  33. data/lib/indexmap/writer.rb +89 -34
  34. data/lib/indexmap.rb +14 -1
  35. data/lib/tasks/indexmap_tasks.rake +21 -2
  36. data/test/indexmap/correctness_test.rb +153 -0
  37. data/test/indexmap/install_generator_test.rb +36 -4
  38. data/test/indexmap/parser_test.rb +77 -0
  39. data/test/indexmap/pinger/google_test.rb +22 -0
  40. data/test/indexmap/pinger/index_now_test.rb +168 -0
  41. data/test/indexmap/publication_test.rb +280 -0
  42. data/test/indexmap/robots_test.rb +73 -0
  43. data/test/indexmap/sitemaps_controller_test.rb +50 -0
  44. data/test/indexmap/storage_test.rb +27 -0
  45. data/test/indexmap/task_runner_test.rb +24 -0
  46. data/test/indexmap/validator_test.rb +14 -0
  47. data/test/indexmap/writer_test.rb +42 -0
  48. data/test/integration/queue_checks.rb +244 -0
  49. data/test/release_task_test.rb +153 -0
  50. metadata +22 -5
  51. data/lib/indexmap/storage/file.rb +0 -11
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class IndexmapCorrectnessTest < Minitest::Test
6
+ def test_resolves_only_the_selected_source_once_per_generation
7
+ %i[index single_file].each do |format|
8
+ calls = Hash.new(0)
9
+ configuration = configuration_with
10
+ configuration.format = format
11
+ configuration.entries = -> do
12
+ calls[:entries] += 1
13
+ [Indexmap::Entry.new(loc: "https://example.com/page")]
14
+ end
15
+ configuration.sections = -> do
16
+ calls[:sections] += 1
17
+ [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [{loc: "https://example.com/page"}])]
18
+ end
19
+
20
+ 2.times { Indexmap.create(configuration: configuration) }
21
+
22
+ assert_equal((format == :index) ? {sections: 2} : {entries: 2}, calls)
23
+ end
24
+ end
25
+
26
+ def test_url_count_boundary
27
+ body = urlset((1..50_000).map { |id| "<url><loc>https://example.com/#{id}</loc></url>" }.join)
28
+ assert validate(body)
29
+
30
+ error = assert_raises(Indexmap::ValidationError) do
31
+ validate(body.sub("</urlset>", "<url><loc>https://example.com/extra</loc></url></urlset>"))
32
+ end
33
+ assert_match "50,000", error.message
34
+ end
35
+
36
+ def test_index_count_limit_precedes_child_reads
37
+ body = index((1..50_001).map { |id| "https://example.com/sitemap-#{id}.xml" })
38
+ error = assert_raises(Indexmap::ValidationError) { validate(body) }
39
+ assert_match "50,000", error.message
40
+ end
41
+
42
+ def test_uncompressed_utf8_byte_boundary
43
+ body = urlset("<url><loc>https://example.com/page</loc></url>")
44
+ padding = 52_428_800 - body.bytesize
45
+ comments = []
46
+ while padding > 1_000_007
47
+ comments << "<!--#{"x" * 1_000_000}-->"
48
+ padding -= 1_000_007
49
+ end
50
+ comments << "<!--#{"x" * (padding - 7)}-->"
51
+ body = body.sub("</urlset>", "#{comments.join}</urlset>")
52
+ assert_equal 52_428_800, body.bytesize
53
+ assert validate(body)
54
+
55
+ error = assert_raises(Indexmap::ValidationError) { validate(body.sub("page", "pagé")) }
56
+ assert_match "52,428,800", error.message
57
+ end
58
+
59
+ def test_rejects_foreign_child_origins_even_when_the_local_file_exists
60
+ %w[https://other.example/sitemap-child.xml http://example.com/sitemap-child.xml https://example.com:444/sitemap-child.xml //other.example/sitemap-child.xml].each do |location|
61
+ configuration = configuration_with
62
+ configuration.storage.write("sitemap.xml", index([location]))
63
+ configuration.storage.write("sitemap-child.xml", urlset("<url><loc>https://example.com/page</loc></url>"))
64
+
65
+ assert_raises(Indexmap::ValidationError) { Indexmap::Validator.new(configuration: configuration).validate! }
66
+ end
67
+ end
68
+
69
+ def test_rejects_self_and_multi_file_cycles
70
+ configuration = configuration_with
71
+ configuration.storage.write("sitemap.xml", index(["https://example.com/sitemap.xml"]))
72
+ error = assert_raises(Indexmap::ValidationError) { Indexmap::Validator.new(configuration: configuration).validate! }
73
+ assert_match "cycle", error.message
74
+
75
+ configuration.storage.write("sitemap.xml", index(["https://example.com/sitemap-child.xml"]))
76
+ configuration.storage.write("sitemap-child.xml", index(["https://example.com/sitemap.xml"]))
77
+ assert_raises(Indexmap::ValidationError) { Indexmap::Validator.new(configuration: configuration).validate! }
78
+ end
79
+
80
+ def test_rejects_dtds_and_unsafe_child_paths
81
+ assert_raises(Indexmap::ValidationError) do
82
+ validate('<!DOCTYPE urlset [<!ENTITY example "https://example.com/page">]>' + urlset("<url><loc>&example;</loc></url>"))
83
+ end
84
+ %w[../outside.xml https://example.com/sitemap.xml?query=1 https://example.com/sitemap.xml#fragment].each do |location|
85
+ assert_raises(Indexmap::ValidationError) { validate(index([location])) }
86
+ end
87
+ end
88
+
89
+ def test_rejects_missing_locations_and_incorrect_namespaces
90
+ [urlset("<url/>"), urlset("<url><loc/></url>"), index(["", "https://example.com/child.xml"]),
91
+ '<urlset xmlns="https://other.example/schema"><url><loc>https://example.com/page</loc></url></urlset>'].each do |body|
92
+ assert_raises(Indexmap::ValidationError) { validate(body) }
93
+ end
94
+ assert_raises(Indexmap::ValidationError) do
95
+ validate(urlset("<url><loc>https://user:password@example.com/page</loc></url>"))
96
+ end
97
+ end
98
+
99
+ def test_validates_deep_indexes_without_recursion_or_repeated_reads
100
+ configuration = configuration_with
101
+ storage = configuration.storage
102
+ reads = Hash.new(0)
103
+ storage.define_singleton_method(:read) do |name|
104
+ reads[name] += 1
105
+ super(name)
106
+ end
107
+ names = ["sitemap.xml", *(1..1200).map { |id| "sitemap-#{id}.xml" }]
108
+ names.each_cons(2) { |parent, child| storage.write(parent, index(["https://example.com/#{child}"])) }
109
+ storage.write(names.last, urlset("<url><loc>https://example.com/page</loc></url>"))
110
+
111
+ validator = Indexmap::Validator.new(configuration: configuration)
112
+ assert validator.validate!
113
+ assert_equal ["https://example.com/page"], validator.entries.map(&:loc)
114
+ assert_equal names.size, reads.size
115
+ assert reads.values.all? { |count| count == 1 }
116
+ end
117
+
118
+ def test_location_length_and_repeated_fields_are_bounded
119
+ prefix = "https://example.com/"
120
+ location = prefix + "a" * (2047 - prefix.length)
121
+ assert validate(urlset("<url><loc>#{location}</loc></url>"))
122
+ assert_raises(Indexmap::ValidationError) { validate(urlset("<url><loc>#{location}x</loc></url>")) }
123
+ assert_raises(Indexmap::ValidationError) do
124
+ validate(urlset("<url><loc>https://example.com/</loc><loc>https://example.com/other</loc></url>"))
125
+ end
126
+ assert_raises(Indexmap::ValidationError) do
127
+ validate(urlset("<url><loc>https://example.com/</loc><lastmod/><lastmod/></url>"))
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def configuration_with
134
+ Indexmap::Configuration.new.tap do |configuration|
135
+ configuration.base_url = "https://example.com"
136
+ configuration.storage = Indexmap::Storage::Memory.new
137
+ end
138
+ end
139
+
140
+ def validate(body)
141
+ configuration = configuration_with
142
+ configuration.storage.write("sitemap.xml", body)
143
+ Indexmap::Validator.new(configuration: configuration).validate!
144
+ end
145
+
146
+ def urlset(body)
147
+ %(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">#{body}</urlset>)
148
+ end
149
+
150
+ def index(locations)
151
+ %(<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">#{locations.map { |loc| "<sitemap><loc>#{loc}</loc></sitemap>" }.join}</sitemapindex>)
152
+ end
153
+ end
@@ -19,8 +19,8 @@ class IndexmapInstallGeneratorTest < Minitest::Test
19
19
  rakefile = File.read(File.join(destination, "Rakefile"))
20
20
 
21
21
  assert File.exist?(initializer)
22
- assert_includes File.read(initializer), "module IndexmapConfiguration"
23
- assert_includes File.read(initializer), "IndexmapConfiguration.apply if defined?(Indexmap)"
22
+ assert_includes File.read(initializer), "module HostConfiguration"
23
+ assert_includes File.read(initializer), "Indexmap::HostConfiguration.apply if defined?(Indexmap::Configuration)"
24
24
  assert_includes File.read(initializer), "Search-engine pings start from config.index_filename"
25
25
  assert_includes rakefile, 'require "indexmap/tasks"'
26
26
  assert_operator rakefile.index('require "indexmap/tasks"'), :<, rakefile.index('require_relative "config/application"')
@@ -33,7 +33,7 @@ class IndexmapInstallGeneratorTest < Minitest::Test
33
33
 
34
34
  module Rails
35
35
  def self.public_path
36
- Pathname.new("public")
36
+ Pathname.new(ENV.fetch("INDEXMAP_TEST_PUBLIC_PATH"))
37
37
  end
38
38
  end
39
39
 
@@ -41,12 +41,14 @@ class IndexmapInstallGeneratorTest < Minitest::Test
41
41
  load ARGV.fetch(0)
42
42
 
43
43
  configuration = Indexmap.configuration
44
- IndexmapConfiguration.apply
44
+ Indexmap::HostConfiguration.apply
45
45
  abort "configuration replaced" unless Indexmap.configuration.equal?(configuration)
46
46
  abort "base URL missing" unless configuration.base_url == "http://localhost:3000"
47
47
  abort "index filename missing" unless configuration.index_filename == "sitemap.xml"
48
+ abort "storage rebuilt" unless configuration.storage.equal?(configuration.storage)
48
49
  RUBY
49
50
  _stdout, stderr, status = Open3.capture3(
51
+ {"INDEXMAP_TEST_PUBLIC_PATH" => File.join(destination, "public")},
50
52
  RbConfig.ruby,
51
53
  "-I#{File.expand_path("../../lib", __dir__)}",
52
54
  "-e",
@@ -56,4 +58,34 @@ class IndexmapInstallGeneratorTest < Minitest::Test
56
58
  assert status.success?, stderr
57
59
  end
58
60
  end
61
+
62
+ def test_loads_tasks_after_rails_has_removed_the_command_from_argv
63
+ Dir.mktmpdir do |destination|
64
+ File.write(File.join(destination, "Rakefile"), <<~RUBY)
65
+ require_relative "config/application"
66
+ Rails.application.load_tasks
67
+ RUBY
68
+ Indexmap::Generators::InstallGenerator.start([], destination_root: destination)
69
+ FileUtils.mkdir_p(File.join(destination, "config"))
70
+ File.write(File.join(destination, "config/application.rb"), <<~RUBY)
71
+ abort "Indexmap loaded too late" unless defined?(Indexmap::Configuration)
72
+ module Rails
73
+ def self.application = self
74
+ def self.load_tasks = nil
75
+ end
76
+ RUBY
77
+ script = <<~RUBY
78
+ require "rake"
79
+ Rake.application.init("rake", ["indexmap:sitemap:create"])
80
+ ARGV.clear
81
+ load ARGV_FILE
82
+ abort "task missing" unless Rake::Task.task_defined?("indexmap:sitemap:create")
83
+ RUBY
84
+ script = script.sub("ARGV_FILE", File.join(destination, "Rakefile").dump)
85
+ _stdout, stderr, status = Open3.capture3(
86
+ RbConfig.ruby, "-I#{File.expand_path("../../lib", __dir__)}", "-e", script
87
+ )
88
+ assert status.success?, stderr
89
+ end
90
+ end
59
91
  end
@@ -119,4 +119,81 @@ class IndexmapParserTest < Minitest::Test
119
119
 
120
120
  assert_equal ["/pages/pricing"], parser.paths
121
121
  end
122
+
123
+ def test_rejects_cross_origin_children_including_scheme_relative_urls
124
+ ["https://other.example/child.xml", "//other.example/child.xml", "http://www.example.com/child.xml",
125
+ "https://www.example.com:444/child.xml", "https://user@www.example.com/child.xml"].each do |child|
126
+ stub_request(:get, "https://www.example.com/sitemap.xml").to_return(
127
+ body: "<sitemapindex><sitemap><loc>#{child}</loc></sitemap></sitemapindex>"
128
+ )
129
+ error = assert_raises(Indexmap::ValidationError) do
130
+ Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").entries
131
+ end
132
+ assert_match(/source origin/, error.message)
133
+ end
134
+ assert_requested :get, "https://www.example.com/sitemap.xml", times: 5
135
+ end
136
+
137
+ def test_redirects_are_limited_to_the_source_origin
138
+ stub_request(:get, "https://www.example.com/sitemap.xml")
139
+ .to_return(status: 302, headers: {"Location" => "/current.xml"})
140
+ stub_request(:get, "https://www.example.com/current.xml")
141
+ .to_return(body: "<urlset><url><loc>https://www.example.com/page</loc></url></urlset>")
142
+ assert_equal ["/page"], Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").paths
143
+
144
+ stub_request(:get, "https://www.example.com/current.xml")
145
+ .to_return(status: 302, headers: {"Location" => "http://169.254.169.254/metadata"})
146
+ assert_raises(Indexmap::ValidationError) do
147
+ Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").entries
148
+ end
149
+ assert_not_requested :get, "http://169.254.169.254/metadata"
150
+ end
151
+
152
+ def test_redirect_loops_stop
153
+ stub_request(:get, "https://www.example.com/sitemap.xml")
154
+ .to_return(status: 302, headers: {"Location" => "/sitemap.xml"})
155
+ assert_empty Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").entries
156
+ assert_requested :get, "https://www.example.com/sitemap.xml", times: 4
157
+ end
158
+
159
+ def test_rejects_oversized_remote_responses_with_and_without_content_length
160
+ [{}, {"Content-Length" => (Indexmap::Validator::MAX_BYTES + 1).to_s}].each do |headers|
161
+ stub_request(:get, "https://www.example.com/sitemap.xml")
162
+ .to_return(body: "x" * (Indexmap::Validator::MAX_BYTES + 1), headers: headers)
163
+ assert_raises(Indexmap::ValidationError) do
164
+ Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").entries
165
+ end
166
+ end
167
+ end
168
+
169
+ def test_rejects_oversized_redirect_and_error_bodies
170
+ [302, 500].each do |status|
171
+ stub_request(:get, "https://www.example.com/sitemap.xml")
172
+ .to_return(status: status, body: "x" * (Indexmap::Validator::MAX_BYTES + 1))
173
+ assert_raises(Indexmap::ValidationError) do
174
+ Indexmap::Parser.new(source: "https://www.example.com/sitemap.xml").entries
175
+ end
176
+ end
177
+ end
178
+
179
+ def test_enumerates_storage_without_whole_file_reads_and_handles_cycles
180
+ storage = Indexmap::Storage::Memory.new
181
+ storage.write("sitemap.xml", "<sitemapindex><sitemap><loc>sitemap.xml</loc></sitemap><sitemap><loc>child.xml</loc></sitemap></sitemapindex>")
182
+ storage.write("child.xml", "<urlset><url><loc>https://example.com/a</loc><lastmod>2026-04-21</lastmod></url><url><loc>https://example.com/b</loc></url></urlset>")
183
+ storage.define_singleton_method(:metadata) { |name| {bytes: files.fetch(name).body.bytesize} }
184
+ storage.define_singleton_method(:open) { |name, &block| block.call(StringIO.new(files.fetch(name).body)) }
185
+ storage.define_singleton_method(:read) { |*| raise "whole-file read" }
186
+ parser = Indexmap::Parser.new(storage: storage)
187
+ entries = parser.each_entry.to_a
188
+ assert_equal ["https://example.com/a", "https://example.com/b"], entries.map(&:loc)
189
+ assert_equal ["2026-04-21", nil], entries.map(&:lastmod)
190
+ refute parser.instance_variable_defined?(:@entries)
191
+ end
192
+
193
+ def test_rejects_dtds_without_fetching_entities
194
+ storage = Indexmap::Storage::Memory.new
195
+ storage.write("sitemap.xml", '<!DOCTYPE urlset [<!ENTITY x SYSTEM "http://example.com/secret">]><urlset><url><loc>&x;</loc></url></urlset>')
196
+ assert_raises(Indexmap::ValidationError) { Indexmap::Parser.new(storage: storage).entries }
197
+ assert_not_requested :get, "http://example.com/secret"
198
+ end
122
199
  end
@@ -159,6 +159,28 @@ class IndexmapPingerGoogleTest < Minitest::Test
159
159
  assert_nil service.submitted
160
160
  end
161
161
 
162
+ def test_counts_unique_urls_across_disk_sort_runs_without_whole_file_reads
163
+ storage = Indexmap::Storage::Memory.new(public_url: "https://www.example.com")
164
+ rows = (1..10_001).map { |id| "<url><loc>https://www.example.com/#{id}</loc></url>" }.join
165
+ storage.write("sitemap.xml", "<urlset>#{rows}#{rows}</urlset>")
166
+ storage.define_singleton_method(:metadata) { |name| {bytes: files.fetch(name).body.bytesize} }
167
+ storage.define_singleton_method(:open) { |name, &block| block.call(StringIO.new(files.fetch(name).body)) }
168
+ storage.define_singleton_method(:read) { |*| raise "whole-file read" }
169
+ configuration = configuration_with(storage: storage)
170
+ configuration.google.credentials = '{"type":"service_account"}'
171
+ calls = 0
172
+ configuration.storage = -> {
173
+ calls += 1
174
+ storage
175
+ }
176
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
177
+ result = Indexmap::Pinger::Google.new(
178
+ configuration: configuration, service: service, credentials_builder: ->(**) { :fake_authorizer }
179
+ ).ping
180
+ assert_equal 10_001, result[:url_count]
181
+ assert_equal 1, calls
182
+ end
183
+
162
184
  private
163
185
 
164
186
  def configuration_with(storage: Indexmap::Storage::Memory.new(public_url: "https://www.example.com"))
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "test_helper"
4
+ require "indexmap/tasks"
4
5
 
5
6
  class IndexmapPingerIndexNowTest < Minitest::Test
6
7
  VALID_KEY = "1234567890abcdef1234567890abcdef"
@@ -207,6 +208,173 @@ class IndexmapPingerIndexNowTest < Minitest::Test
207
208
  assert_equal "00000000000000000000000000000000.txt", filename
208
209
  end
209
210
 
211
+ def test_date_only_lastmods_include_the_cutoff_day
212
+ configuration = configuration_with(storage: sitemap_storage(
213
+ marketing_lastmod: "2026-04-18", insights_lastmod: "2026-04-17"
214
+ ))
215
+ configuration.index_now.key = VALID_KEY
216
+ configuration.index_now.dry_run = true
217
+
218
+ with_env("SINCE" => "2026-04-18T23:59:59Z") do
219
+ assert_equal 1, Indexmap::Pinger::IndexNow.new(configuration: configuration).ping.fetch(:url_count)
220
+ end
221
+ end
222
+
223
+ def test_rejects_invalid_batch_sizes_before_sending
224
+ configuration = configuration_with(storage: sitemap_storage)
225
+ configuration.index_now.key = VALID_KEY
226
+ %w[0 -1 invalid 1.5 10001].each do |value|
227
+ with_env("INDEXNOW_MAX_URLS_PER_REQUEST" => value) do
228
+ assert_raises(Indexmap::ConfigurationError) do
229
+ Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
230
+ end
231
+ end
232
+ end
233
+ assert_not_requested(:post, "https://api.indexnow.org/indexnow")
234
+ end
235
+
236
+ def test_invalid_since_does_not_fall_back_to_recent_hours
237
+ configuration = configuration_with(storage: sitemap_storage)
238
+ configuration.index_now.key = VALID_KEY
239
+ with_env("SINCE" => "invalid", "INDEXNOW_RECENT_HOURS" => "24") do
240
+ assert_raises(ArgumentError) { Indexmap::Pinger::IndexNow.new(configuration: configuration).ping }
241
+ end
242
+ end
243
+
244
+ def test_plan_without_a_key_reports_exact_selection_and_exclusions
245
+ configuration = configuration_with(storage: sitemap_storage)
246
+ with_env("SINCE" => "2026-04-15T00:00:00Z") do
247
+ rows = Indexmap::Pinger::IndexNow.new(configuration: configuration).plan.to_a
248
+ assert_equal 2, rows.size
249
+ selected = rows.find { |row| row.fetch(:selected) }
250
+ assert_equal "https://www.example.com/pages/features", selected.fetch(:loc)
251
+ assert_equal "sitemap-marketing.xml", selected.fetch(:source_sitemap)
252
+ assert_equal :recent, selected.fetch(:reason)
253
+ assert_equal 1, selected.fetch(:batch)
254
+ assert selected.fetch(:missing_key)
255
+ assert_equal :before_cutoff, rows.find { |row| !row.fetch(:selected) }.fetch(:reason)
256
+ end
257
+ assert_not_requested(:post, "https://api.indexnow.org/indexnow")
258
+ end
259
+
260
+ def test_plan_matches_submitted_batches_and_explains_undated_entries
261
+ configuration = configuration_with(storage: sitemap_storage(marketing_lastmod: ""))
262
+ configuration.index_now.key = VALID_KEY
263
+ configuration.index_now.max_urls_per_request = 1
264
+ stub_request(:post, "https://api.indexnow.org/indexnow").to_return(status: 200)
265
+ with_env("SINCE" => "2026-04-15T00:00:00Z") do
266
+ pinger = Indexmap::Pinger::IndexNow.new(configuration: configuration)
267
+ rows = pinger.plan.select { |row| row.fetch(:selected) }
268
+ assert_equal :missing_lastmod, rows.first.fetch(:reason)
269
+ events = []
270
+ pinger.ping { |event| events << event }
271
+ assert_equal rows.map { |row| row.fetch(:loc) }, events.select { |event| event[:event] == :attempt_started }.flat_map { |event| event[:urls] }
272
+ refute_includes JSON.generate(events), VALID_KEY
273
+ end
274
+ end
275
+
276
+ def test_transport_failure_preserves_earlier_receipts_and_reports_uncertainty
277
+ configuration = configuration_with(storage: sitemap_storage)
278
+ configuration.index_now.key = VALID_KEY
279
+ configuration.index_now.max_urls_per_request = 1
280
+ stub_request(:post, "https://api.indexnow.org/indexnow")
281
+ .to_return(status: 202).then.to_timeout
282
+ events = []
283
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping { |event| events << event }
284
+ assert_equal :partial, result.fetch(:status)
285
+ assert_equal 1, result.fetch(:url_count)
286
+ assert_equal :unknown_delivery, result.fetch(:failures).first.fetch(:reason)
287
+ receipts = events.select { |event| event[:event] == :attempt_finished }
288
+ assert_equal 202, receipts.first.fetch(:status_code)
289
+ assert_equal :validation_pending, receipts.first.fetch(:receipt)
290
+ assert_equal :unknown_delivery, receipts.last.fetch(:reason)
291
+ end
292
+
293
+ def test_plan_task_exports_json_lines_without_sending
294
+ configuration = configuration_with(storage: sitemap_storage)
295
+ Rake::Task.define_task(:environment)
296
+ task = Rake::Task["indexmap:index_now:plan"]
297
+ task.reenable
298
+ output, = Indexmap.stub(:configuration, configuration) { capture_io { task.invoke } }
299
+ rows = output.lines.map { |line| JSON.parse(line) }
300
+ assert_equal 2, rows.size
301
+ assert rows.all? { |row| row.fetch("selected") && row.fetch("missing_key") }
302
+ assert_not_requested(:post, "https://api.indexnow.org/indexnow")
303
+ end
304
+
305
+ def test_ping_task_report_contains_exact_attempts_and_a_summary
306
+ configuration = configuration_with(storage: sitemap_storage)
307
+ configuration.index_now.key = VALID_KEY
308
+ stub_request(:post, "https://api.indexnow.org/indexnow").to_return(status: 200)
309
+ Rake::Task.define_task(:environment)
310
+ task = Rake::Task["indexmap:index_now:ping"]
311
+ task.reenable
312
+ with_env("INDEXNOW_REPORT" => "1") do
313
+ output, = Indexmap.stub(:configuration, configuration) { capture_io { task.invoke } }
314
+ events = output.lines.map { |line| JSON.parse(line) }
315
+ assert_equal %w[attempt_started attempt_finished summary], events.map { |event| event.fetch("event") }
316
+ assert_equal 2, events.first.fetch("urls").size
317
+ assert_equal "received", events[1].fetch("receipt")
318
+ assert_equal "submitted", events.last.fetch("status")
319
+ refute_includes output, VALID_KEY
320
+ end
321
+ end
322
+
323
+ def test_invalid_sitemap_fails_before_any_submission
324
+ configuration = configuration_with(storage: sitemap_storage)
325
+ configuration.index_now.key = VALID_KEY
326
+ configuration.storage.write("sitemap-insights.xml", '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://private.example/secret</loc></url></urlset>')
327
+ assert_raises(Indexmap::ValidationError) { Indexmap::Pinger::IndexNow.new(configuration: configuration).ping }
328
+ assert_not_requested(:post, "https://api.indexnow.org/indexnow")
329
+ end
330
+
331
+ def test_retries_only_a_transient_failed_batch_and_reports_every_attempt
332
+ configuration = configuration_with(storage: sitemap_storage)
333
+ configuration.index_now.key = VALID_KEY
334
+ configuration.index_now.max_attempts = 3
335
+ configuration.index_now.max_wait = 0
336
+ configuration.index_now.max_urls_per_request = 1
337
+ request = stub_request(:post, "https://api.indexnow.org/indexnow")
338
+ .to_return(status: 200).then.to_return(status: 503).then.to_return(status: 200)
339
+ events = []
340
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping { |event| events << event }
341
+ assert_equal :submitted, result.fetch(:status)
342
+ assert_equal 2, result.fetch(:url_count)
343
+ assert_requested request, times: 3
344
+ started = events.select { |event| event[:event] == :attempt_started }
345
+ assert_equal [1, 2, 2], started.map { |event| event.fetch(:batch) }
346
+ assert_equal [1, 1, 2], started.map { |event| event.fetch(:attempt) }
347
+ end
348
+
349
+ def test_does_not_retry_permanent_errors_or_shortcut_long_retry_after
350
+ [403, 429].each do |status|
351
+ configuration = configuration_with(storage: sitemap_storage)
352
+ configuration.index_now.key = VALID_KEY
353
+ configuration.index_now.max_attempts = 3
354
+ configuration.index_now.max_wait = 1
355
+ request = stub_request(:post, "https://api.indexnow.org/indexnow")
356
+ .to_return(status: status, headers: {"Retry-After" => "120"})
357
+ assert_equal :failed, Indexmap::Pinger::IndexNow.new(configuration: configuration).ping.fetch(:status)
358
+ assert_requested request, times: 1
359
+ WebMock.reset!
360
+ end
361
+ end
362
+
363
+ def test_selection_and_sending_share_one_resolved_storage
364
+ config = configuration_with(storage: sitemap_storage)
365
+ config.index_now.key = VALID_KEY
366
+ storage = config.storage
367
+ calls = 0
368
+ config.storage = -> {
369
+ calls += 1
370
+ storage
371
+ }
372
+ stub_request(:post, "https://api.indexnow.org/indexnow").to_return(status: 200)
373
+ result = Indexmap::Pinger::IndexNow.new(configuration: config).ping
374
+ assert_equal :submitted, result.fetch(:status)
375
+ assert_equal 1, calls
376
+ end
377
+
210
378
  private
211
379
 
212
380
  def with_env(overrides)