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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +287 -35
- data/UPGRADE.md +361 -0
- data/docs/architecture.md +55 -0
- data/docs/deployment.md +223 -0
- data/docs/domain-terms.md +27 -0
- data/docs/dynamic-sitemaps.md +302 -0
- data/docs/search-engine-ping.md +368 -0
- data/docs/storage.md +94 -0
- data/lib/generators/indexmap/install_generator.rb +1 -1
- data/lib/generators/indexmap/templates/initializer.rb.tt +18 -18
- data/lib/indexmap/artifact.rb +9 -0
- data/lib/indexmap/configuration.rb +12 -1
- data/lib/indexmap/creator.rb +45 -42
- data/lib/indexmap/index_now_configuration.rb +3 -1
- data/lib/indexmap/output.rb +19 -5
- data/lib/indexmap/parser.rb +112 -52
- data/lib/indexmap/pinger/base.rb +6 -5
- data/lib/indexmap/pinger/google.rb +12 -8
- data/lib/indexmap/pinger/index_now.rb +97 -52
- data/lib/indexmap/publication.rb +89 -0
- data/lib/indexmap/robots.rb +67 -0
- data/lib/indexmap/robots_controller.rb +13 -0
- data/lib/indexmap/sitemaps_controller.rb +57 -0
- data/lib/indexmap/storage/active_storage.rb +132 -1
- data/lib/indexmap/storage/filesystem.rb +182 -11
- data/lib/indexmap/storage/memory.rb +1 -1
- data/lib/indexmap/task_runner.rb +21 -28
- data/lib/indexmap/validator/duplicates.rb +83 -0
- data/lib/indexmap/validator.rb +171 -59
- data/lib/indexmap/version.rb +1 -1
- data/lib/indexmap/writer.rb +89 -34
- data/lib/indexmap.rb +14 -1
- data/lib/tasks/indexmap_tasks.rake +21 -2
- data/test/indexmap/correctness_test.rb +153 -0
- data/test/indexmap/install_generator_test.rb +36 -4
- data/test/indexmap/parser_test.rb +77 -0
- data/test/indexmap/pinger/google_test.rb +22 -0
- data/test/indexmap/pinger/index_now_test.rb +168 -0
- data/test/indexmap/publication_test.rb +280 -0
- data/test/indexmap/robots_test.rb +73 -0
- data/test/indexmap/sitemaps_controller_test.rb +50 -0
- data/test/indexmap/storage_test.rb +27 -0
- data/test/indexmap/task_runner_test.rb +24 -0
- data/test/indexmap/validator_test.rb +14 -0
- data/test/indexmap/writer_test.rb +42 -0
- data/test/integration/queue_checks.rb +244 -0
- data/test/release_task_test.rb +153 -0
- metadata +22 -5
- 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
|
|
23
|
-
assert_includes File.read(initializer), "
|
|
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("
|
|
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
|
-
|
|
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)
|