indexmap 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a655a2fa64beda7c2724d6fb4f283a13a5baf47519863c489e982efa86f2c32
4
- data.tar.gz: 42dac8a1312d05bba81df9534bba52eaa69d55c236688473757252a1038dae06
3
+ metadata.gz: 9b0f3c8b9175d0c18c39e52b61b3934129dfa8b8b637d06ad3d0d9adc30eb995
4
+ data.tar.gz: 3baf06c566ce17d63e189aabfdd7f3e7b4047d5908fc6a58f3e017da5ecef1de
5
5
  SHA512:
6
- metadata.gz: d0615154aef9cf51613b73c46f834efb16c53b77dfb3611f8f980a8ae5c1e8eedaa653ca84ca5610a27b58377717e7c3acbd0fc1cdb162df90fd84d660912ed6
7
- data.tar.gz: 796aeed197f26684ebbd74bb7b47d37c878fea855c02c4ec0636cd7ce28c44f32fb73a3c2a911955d194d175c8c80ce351c8b2f955e03707543a88e4ab740763
6
+ metadata.gz: 700e0cd5485cd433ceaa520a2d634eb4e5ccf93d4d8993224fae61acdf7dacd73c91029e24ff750cc58ce8704a5b00bb4ed66eff113d0693c46df75fc5aea4df
7
+ data.tar.gz: aa51396dad778e50b18a53606d316d5f9008ed358cecb7b75c0f7dc72f24d47a3959750102fdddafe50fbb31688256e16b07ec9c9c0ae53cb9431148593bb250
data/CHANGELOG.md CHANGED
@@ -5,12 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.4.0] - 2026-04-22
8
+ ## [0.4.2] - 2026-04-23
9
9
 
10
10
 
11
- ### Documentation
11
+ ### Fixed
12
12
 
13
- - improve the task messages (#4)
13
+ - harden sitemap pinging and indexnow key handling (#7)
14
14
 
15
15
 
16
16
 
@@ -17,10 +17,13 @@ module Indexmap
17
17
  def ping
18
18
  if google_configuration.credentials.to_s.strip.empty?
19
19
  logger.debug("Google sitemap credentials not configured.")
20
- return
20
+ return {status: :skipped, reason: :missing_credentials}
21
21
  end
22
22
 
23
- super
23
+ results = sitemap_files.map { |sitemap_file| ping_sitemap(sitemap_file) }
24
+ return {status: :skipped, reason: :no_sitemaps} if results.empty?
25
+
26
+ summarize_results(results)
24
27
  end
25
28
 
26
29
  private
@@ -35,18 +38,31 @@ module Indexmap
35
38
  sitemap_url = URI.join(host, File.basename(sitemap_file)).to_s
36
39
 
37
40
  unless authorized?
38
- logger.error("Google Search Console does not have access to the site: #{root_domain}")
39
- return
41
+ logger.debug("Google Search Console does not have access to the site: #{root_domain}")
42
+ return {
43
+ status: :failed,
44
+ reason: :unauthorized,
45
+ property: property_identifier,
46
+ root_domain: root_domain
47
+ }
40
48
  end
41
49
 
42
50
  webmasters_service.submit_sitemap(property_identifier, sitemap_url)
43
51
  logger.debug { "Successfully pinged Google with sitemap: #{sitemap_url}" }
52
+ {status: :submitted, sitemap_url: sitemap_url}
44
53
  rescue ::Google::Apis::ClientError => e
45
54
  logger.debug { "Failed to ping Google for #{sitemap_url}. Status: #{e.status_code}, Body: #{e.body}" }
55
+ {
56
+ status: :failed,
57
+ reason: :client_error,
58
+ sitemap_url: sitemap_url,
59
+ status_code: e.status_code,
60
+ body: e.body
61
+ }
46
62
  end
47
63
 
48
64
  def authorized?
49
- webmasters_service.list_sites.site_entry.any? { |site| site.site_url.include?(root_domain) }
65
+ @authorized ||= accessible_site_urls.include?(property_identifier)
50
66
  end
51
67
 
52
68
  def property_identifier
@@ -73,6 +89,25 @@ module Indexmap
73
89
  scope: scope
74
90
  )
75
91
  end
92
+
93
+ def summarize_results(results)
94
+ submitted = results.select { |result| result[:status] == :submitted }
95
+ failures = results.select { |result| result[:status] == :failed }
96
+
97
+ return {status: :submitted, sitemap_count: submitted.count, submitted: submitted} if failures.empty?
98
+ return {status: :failed, sitemap_count: 0, failures: failures} if submitted.empty?
99
+
100
+ {
101
+ status: :partial,
102
+ sitemap_count: submitted.count,
103
+ submitted: submitted,
104
+ failures: failures
105
+ }
106
+ end
107
+
108
+ def accessible_site_urls
109
+ @accessible_site_urls ||= Array(webmasters_service.list_sites.site_entry).map(&:site_url)
110
+ end
76
111
  end
77
112
  end
78
113
  end
@@ -8,6 +8,8 @@ require "time"
8
8
  module Indexmap
9
9
  module Pinger
10
10
  class IndexNow < Base
11
+ KEY_FORMAT = /\A[a-f0-9]{32}\z/
12
+
11
13
  def initialize(configuration: Indexmap.configuration, connection: nil)
12
14
  super(configuration: configuration)
13
15
  @connection = connection
@@ -17,42 +19,45 @@ module Indexmap
17
19
  api_key = read_api_key
18
20
  unless api_key
19
21
  logger.debug("IndexNow API key is not configured.")
20
- return
22
+ return {status: :skipped, reason: :missing_key}
21
23
  end
22
24
 
23
25
  entries = entries_to_ping
24
26
  if entries.empty?
25
27
  logger.debug("IndexNow: no URLs matched the current filter.")
26
- return
28
+ return {status: :skipped, reason: :no_urls}
27
29
  end
28
30
 
29
- entries.each_slice(max_urls_per_request) do |batch|
31
+ results = entries.each_slice(max_urls_per_request).map do |batch|
30
32
  urls = batch.map(&:loc)
31
33
 
32
34
  if dry_run?
33
35
  logger.debug { "IndexNow dry-run: would ping #{urls.count} URLs." }
34
- next
36
+ next({status: :dry_run, url_count: urls.count})
35
37
  end
36
38
 
37
39
  submit_batch(api_key: api_key, urls: urls)
38
40
  end
41
+
42
+ summarize_results(results)
39
43
  end
40
44
 
41
45
  def write_key_file(key: index_now_configuration.key, path: nil)
42
- key = key.to_s.strip
46
+ key = normalized_configured_key(key)
43
47
  return if key.empty?
44
48
 
45
49
  path ||= index_now_configuration.key_path(public_path: configuration.public_path, key: key)
46
50
  FileUtils.mkdir_p(path.dirname)
47
- File.write(path, "#{key}\n")
51
+ File.write(path, key)
48
52
  path
49
53
  end
50
54
 
51
55
  def ensure_key_file
52
- configured_key = index_now_configuration.key.to_s.strip
56
+ configured_key = normalized_configured_key(index_now_configuration.key)
53
57
  return write_key_file(key: configured_key) unless configured_key.empty?
54
58
 
55
- return existing_key_file if existing_key_file
59
+ existing_path = existing_key_file
60
+ return existing_path if existing_path
56
61
 
57
62
  key = generated_key
58
63
  write_key_file(key: key, path: configuration.public_path.join("#{key}.txt"))
@@ -141,6 +146,9 @@ module Indexmap
141
146
 
142
147
  def submit_batch(api_key:, urls:)
143
148
  payload = {host: hostname, key: api_key, urlList: urls}
149
+ location = key_location(api_key: api_key)
150
+ payload[:keyLocation] = location if location
151
+
144
152
  response = index_now_connection.post("/indexnow") do |request|
145
153
  request.headers["Content-Type"] = "application/json"
146
154
  request.body = payload.to_json
@@ -148,10 +156,10 @@ module Indexmap
148
156
 
149
157
  if response.success?
150
158
  logger.debug { "Successfully pinged IndexNow with #{urls.count} URLs." }
151
- true
159
+ {status: :submitted, url_count: urls.count}
152
160
  else
153
161
  logger.debug { "Failed to ping IndexNow. Status: #{response.status}, Body: #{response.body}" }
154
- false
162
+ {status: :failed, url_count: urls.count, status_code: response.status, body: response.body}
155
163
  end
156
164
  end
157
165
 
@@ -166,30 +174,88 @@ module Indexmap
166
174
  end
167
175
 
168
176
  def read_api_key
169
- configured_key = index_now_configuration.key.to_s.strip
177
+ configured_key = normalized_configured_key(index_now_configuration.key)
170
178
  return configured_key unless configured_key.empty?
171
179
 
172
- existing_key_file&.read&.strip
180
+ existing_key_file&.read
173
181
  end
174
182
 
175
183
  def existing_key_file
176
184
  configured_path = index_now_configuration.key_path(public_path: configuration.public_path)
177
185
  return configured_path if valid_key_file?(configured_path)
178
186
 
179
- configuration.public_path.glob("*.txt").find { |file| valid_key_file?(file) }
187
+ configuration.public_path.glob("*.txt").sort.find { |file| valid_key_file?(file) }
188
+ end
189
+
190
+ def key_location(api_key:)
191
+ path = index_now_configuration.key_path(public_path: configuration.public_path, key: api_key) || existing_key_file
192
+ return unless path
193
+
194
+ public_path = configuration.public_path.expand_path
195
+ key_path = path.expand_path
196
+ relative_path = key_path.relative_path_from(public_path)
197
+
198
+ URI.join("#{host}/", relative_path.to_s).to_s
199
+ rescue ArgumentError
200
+ nil
180
201
  end
181
202
 
182
203
  def valid_key_file?(path)
183
204
  return false unless path&.file?
184
205
 
185
206
  filename = path.basename(".txt").to_s
186
- return false unless filename.match?(/\A[a-zA-Z0-9-]{8,128}\z/)
207
+ return false unless filename.match?(KEY_FORMAT)
187
208
 
188
- path.read.strip == filename
209
+ path.read == filename
189
210
  end
190
211
 
191
212
  def generated_key
192
- SecureRandom.uuid
213
+ SecureRandom.hex(16)
214
+ end
215
+
216
+ def normalized_configured_key(value)
217
+ key = value.to_s.strip
218
+ return key if key.empty? || key.match?(KEY_FORMAT)
219
+
220
+ raise ConfigurationError, "IndexNow key must be a 32-character lowercase hexadecimal string"
221
+ end
222
+
223
+ def summarize_results(results)
224
+ dry_runs = results.select { |result| result[:status] == :dry_run }
225
+ submitted = results.select { |result| result[:status] == :submitted }
226
+ failures = results.select { |result| result[:status] == :failed }
227
+
228
+ if dry_runs.any?
229
+ return {
230
+ status: :dry_run,
231
+ url_count: dry_runs.sum { |result| result[:url_count] },
232
+ batch_count: dry_runs.count
233
+ }
234
+ end
235
+
236
+ if failures.empty?
237
+ return {
238
+ status: :submitted,
239
+ url_count: submitted.sum { |result| result[:url_count] },
240
+ batch_count: submitted.count
241
+ }
242
+ end
243
+
244
+ if submitted.empty?
245
+ return {
246
+ status: :failed,
247
+ url_count: 0,
248
+ batch_count: 0,
249
+ failures: failures
250
+ }
251
+ end
252
+
253
+ {
254
+ status: :partial,
255
+ url_count: submitted.sum { |result| result[:url_count] },
256
+ batch_count: submitted.count,
257
+ failures: failures
258
+ }
193
259
  end
194
260
  end
195
261
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Indexmap
4
- VERSION = "0.4.0"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -82,7 +82,8 @@ module Indexmap
82
82
  sitemap_sections.each do |section|
83
83
  lines << " <sitemap>"
84
84
  lines << " <loc>#{escape(index_loc(section.filename))}</loc>"
85
- lines << " <lastmod>#{format_lastmod(section_lastmod(section))}</lastmod>"
85
+ lastmod = section_lastmod(section)
86
+ lines << " <lastmod>#{format_lastmod(lastmod)}</lastmod>" if lastmod
86
87
  lines << " </sitemap>"
87
88
  end
88
89
 
@@ -102,7 +103,7 @@ module Indexmap
102
103
 
103
104
  def section_lastmod(section)
104
105
  timestamps = Array(section.entries).map { |entry| comparable_lastmod(normalize_entry(entry).lastmod) }.compact
105
- timestamps.max || Time.now.utc
106
+ timestamps.max
106
107
  end
107
108
 
108
109
  def format_lastmod(value)
@@ -35,14 +35,40 @@ namespace :sitemap do
35
35
  namespace :google do
36
36
  desc "Ping Google Search Console"
37
37
  task ping: :environment do
38
- Indexmap::Pinger::Google.new.ping
38
+ result = Indexmap::Pinger::Google.new.ping
39
+
40
+ case result[:status]
41
+ when :submitted
42
+ puts "Submitted #{result[:sitemap_count]} sitemap #{(result[:sitemap_count] == 1) ? "file" : "files"} to Google Search Console."
43
+ when :partial
44
+ puts "Submitted #{result[:sitemap_count]} sitemap #{(result[:sitemap_count] == 1) ? "file" : "files"} to Google Search Console, with #{result[:failures].count} failure#{"s" unless result[:failures].count == 1}."
45
+ result[:failures].each { |failure| puts format_google_ping_failure(failure) }
46
+ when :failed
47
+ result[:failures].each { |failure| puts format_google_ping_failure(failure) }
48
+ when :skipped
49
+ puts format_google_ping_skip(result)
50
+ end
39
51
  end
40
52
  end
41
53
 
42
54
  namespace :index_now do
43
55
  desc "Ping IndexNow. ENV: SINCE=2026-04-18T10:30:00Z or INDEXNOW_RECENT_HOURS=24"
44
56
  task ping: :environment do
45
- Indexmap::Pinger::IndexNow.new.ping
57
+ result = Indexmap::Pinger::IndexNow.new.ping
58
+
59
+ case result[:status]
60
+ when :submitted
61
+ puts "Submitted #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} to IndexNow in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}."
62
+ when :partial
63
+ puts "Submitted #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} to IndexNow in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}, with #{result[:failures].count} failure#{"s" unless result[:failures].count == 1}."
64
+ result[:failures].each { |failure| puts format_index_now_ping_failure(failure) }
65
+ when :failed
66
+ result[:failures].each { |failure| puts format_index_now_ping_failure(failure) }
67
+ when :dry_run
68
+ puts "IndexNow dry-run: would submit #{result[:url_count]} URL#{"s" unless result[:url_count] == 1} in #{result[:batch_count]} request#{"s" unless result[:batch_count] == 1}."
69
+ when :skipped
70
+ puts format_index_now_ping_skip(result)
71
+ end
46
72
  end
47
73
 
48
74
  desc "Ensure the IndexNow key file exists in public/"
@@ -64,4 +90,46 @@ namespace :sitemap do
64
90
  def public_directory(runner)
65
91
  runner.public_path
66
92
  end
93
+
94
+ def format_google_ping_failure(failure)
95
+ case failure[:reason]
96
+ when :unauthorized
97
+ "Google Search Console does not have access to #{failure[:property]}."
98
+ when :client_error
99
+ "Google ping failed for #{failure[:sitemap_url]} (status #{failure[:status_code]})."
100
+ else
101
+ "Google ping failed."
102
+ end
103
+ end
104
+
105
+ def format_google_ping_skip(result)
106
+ case result[:reason]
107
+ when :missing_credentials
108
+ "Google sitemap credentials are not configured; skipped Google submission."
109
+ when :no_sitemaps
110
+ "No sitemap files found; skipped Google submission."
111
+ else
112
+ "Skipped Google submission."
113
+ end
114
+ end
115
+
116
+ def format_index_now_ping_failure(failure)
117
+ case failure[:status_code]
118
+ when nil
119
+ "IndexNow submission failed."
120
+ else
121
+ "IndexNow submission failed for #{failure[:url_count]} URL#{"s" unless failure[:url_count] == 1} (status #{failure[:status_code]})."
122
+ end
123
+ end
124
+
125
+ def format_index_now_ping_skip(result)
126
+ case result[:reason]
127
+ when :missing_key
128
+ "IndexNow key is not configured and no valid key file was found; skipped IndexNow submission."
129
+ when :no_urls
130
+ "No sitemap URLs matched the current IndexNow filter; skipped IndexNow submission."
131
+ else
132
+ "Skipped IndexNow submission."
133
+ end
134
+ end
67
135
  end
@@ -8,13 +8,15 @@ class IndexmapPingerGoogleTest < Minitest::Test
8
8
 
9
9
  class FakeWebmastersService
10
10
  attr_accessor :authorization
11
- attr_reader :submitted
11
+ attr_reader :submitted, :list_sites_calls
12
12
 
13
13
  def initialize(site_urls:)
14
14
  @site_urls = site_urls
15
+ @list_sites_calls = 0
15
16
  end
16
17
 
17
18
  def list_sites
19
+ @list_sites_calls += 1
18
20
  SiteList.new(@site_urls.map { |site_url| SiteEntry.new(site_url) })
19
21
  end
20
22
 
@@ -40,7 +42,7 @@ class IndexmapPingerGoogleTest < Minitest::Test
40
42
  :fake_authorizer
41
43
  end
42
44
 
43
- Indexmap::Pinger::Google.new(
45
+ result = Indexmap::Pinger::Google.new(
44
46
  configuration: configuration,
45
47
  service: service,
46
48
  credentials_builder: credentials_builder
@@ -49,6 +51,9 @@ class IndexmapPingerGoogleTest < Minitest::Test
49
51
  assert_equal [["{\"type\":\"service_account\"}", "https://www.googleapis.com/auth/webmasters"]], builder_calls
50
52
  assert_equal :fake_authorizer, service.authorization
51
53
  assert_equal ["sc-domain:example.com", "https://www.example.com/sitemap.xml"], service.submitted
54
+ assert_equal :submitted, result[:status]
55
+ assert_equal 1, result[:sitemap_count]
56
+ assert_equal 1, service.list_sites_calls
52
57
  end
53
58
  end
54
59
 
@@ -63,9 +68,76 @@ class IndexmapPingerGoogleTest < Minitest::Test
63
68
 
64
69
  service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
65
70
 
66
- Indexmap::Pinger::Google.new(configuration: configuration, service: service).ping
71
+ result = Indexmap::Pinger::Google.new(configuration: configuration, service: service).ping
67
72
 
68
73
  assert_nil service.submitted
74
+ assert_equal({status: :skipped, reason: :missing_credentials}, result)
75
+ end
76
+ end
77
+
78
+ def test_reports_missing_sitemap_files
79
+ Dir.mktmpdir do |dir|
80
+ configuration = Indexmap::Configuration.new
81
+ configuration.base_url = "https://www.example.com"
82
+ configuration.public_path = Pathname(dir)
83
+ configuration.google.credentials = "{\"type\":\"service_account\"}"
84
+
85
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:example.com"])
86
+ result = Indexmap::Pinger::Google.new(
87
+ configuration: configuration,
88
+ service: service,
89
+ credentials_builder: ->(**) { :fake_authorizer }
90
+ ).ping
91
+
92
+ assert_equal({status: :skipped, reason: :no_sitemaps}, result)
93
+ assert_nil service.submitted
94
+ end
95
+ end
96
+
97
+ def test_reports_google_authorization_failure
98
+ Dir.mktmpdir do |dir|
99
+ public_path = Pathname(dir)
100
+ public_path.join("sitemap.xml").write("<sitemapindex/>")
101
+
102
+ configuration = Indexmap::Configuration.new
103
+ configuration.base_url = "https://www.example.com"
104
+ configuration.public_path = public_path
105
+ configuration.google.credentials = "{\"type\":\"service_account\"}"
106
+
107
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:not-example.org"])
108
+ result = Indexmap::Pinger::Google.new(
109
+ configuration: configuration,
110
+ service: service,
111
+ credentials_builder: ->(**) { :fake_authorizer }
112
+ ).ping
113
+
114
+ assert_equal :failed, result[:status]
115
+ assert_equal 1, result[:failures].count
116
+ assert_equal :unauthorized, result[:failures].first[:reason]
117
+ assert_nil service.submitted
118
+ end
119
+ end
120
+
121
+ def test_google_authorization_requires_exact_property_match
122
+ Dir.mktmpdir do |dir|
123
+ public_path = Pathname(dir)
124
+ public_path.join("sitemap.xml").write("<sitemapindex/>")
125
+
126
+ configuration = Indexmap::Configuration.new
127
+ configuration.base_url = "https://www.example.com"
128
+ configuration.public_path = public_path
129
+ configuration.google.credentials = "{\"type\":\"service_account\"}"
130
+
131
+ service = FakeWebmastersService.new(site_urls: ["sc-domain:myexample.com"])
132
+ result = Indexmap::Pinger::Google.new(
133
+ configuration: configuration,
134
+ service: service,
135
+ credentials_builder: ->(**) { :fake_authorizer }
136
+ ).ping
137
+
138
+ assert_equal :failed, result[:status]
139
+ assert_equal :unauthorized, result[:failures].first[:reason]
140
+ assert_nil service.submitted
69
141
  end
70
142
  end
71
143
  end
@@ -3,17 +3,19 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapPingerIndexNowTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_writes_key_file_from_configuration
7
9
  Dir.mktmpdir do |dir|
8
10
  configuration = Indexmap::Configuration.new
9
11
  configuration.base_url = "https://www.example.com"
10
12
  configuration.public_path = Pathname(dir)
11
- configuration.index_now.key = "test-key"
13
+ configuration.index_now.key = VALID_KEY
12
14
 
13
15
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).write_key_file
14
16
 
15
- assert_equal Pathname(dir).join("test-key.txt"), path
16
- assert_equal "test-key\n", path.read
17
+ assert_equal Pathname(dir).join("#{VALID_KEY}.txt"), path
18
+ assert_equal VALID_KEY, path.read
17
19
  end
18
20
  end
19
21
 
@@ -25,16 +27,16 @@ class IndexmapPingerIndexNowTest < Minitest::Test
25
27
 
26
28
  path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
27
29
 
28
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, path.basename.to_s)
29
- assert_equal "#{path.basename(".txt")}\n", path.read
30
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
31
+ assert_equal path.basename(".txt").to_s, path.read
30
32
  end
31
33
  end
32
34
 
33
35
  def test_pings_using_existing_key_file_when_key_is_not_configured
34
36
  Dir.mktmpdir do |dir|
35
37
  public_path = Pathname(dir)
36
- key_path = public_path.join("test-key-123.txt")
37
- key_path.write("test-key-123\n")
38
+ key_path = public_path.join("#{VALID_KEY}.txt")
39
+ key_path.write(VALID_KEY)
38
40
  write_sitemap_files(
39
41
  public_path,
40
42
  marketing_lastmod: "2026-04-18T00:00:00Z",
@@ -48,12 +50,34 @@ class IndexmapPingerIndexNowTest < Minitest::Test
48
50
  indexnow_url = "https://api.indexnow.org/indexnow"
49
51
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
50
52
 
51
- Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
53
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
52
54
 
53
55
  assert_requested(:post, indexnow_url, times: 1) do |request|
54
56
  payload = JSON.parse(request.body)
55
- assert_equal "test-key-123", payload.fetch("key")
57
+ assert_equal VALID_KEY, payload.fetch("key")
58
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
56
59
  end
60
+ assert_equal :submitted, result[:status]
61
+ assert_equal 2, result[:url_count]
62
+ assert_equal 1, result[:batch_count]
63
+ end
64
+ end
65
+
66
+ def test_ignores_existing_key_file_with_trailing_newline
67
+ Dir.mktmpdir do |dir|
68
+ public_path = Pathname(dir)
69
+ invalid_key_path = public_path.join("1234567890abcdef1234567890abcdef.txt")
70
+ invalid_key_path.write("#{VALID_KEY}\n")
71
+
72
+ configuration = Indexmap::Configuration.new
73
+ configuration.base_url = "https://www.example.com"
74
+ configuration.public_path = public_path
75
+
76
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
77
+
78
+ refute_equal invalid_key_path, path
79
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, path.basename.to_s)
80
+ assert_equal path.basename(".txt").to_s, path.read
57
81
  end
58
82
  end
59
83
 
@@ -69,12 +93,12 @@ class IndexmapPingerIndexNowTest < Minitest::Test
69
93
  configuration = Indexmap::Configuration.new
70
94
  configuration.base_url = "https://www.example.com"
71
95
  configuration.public_path = public_path
72
- configuration.index_now.key = "test-key"
96
+ configuration.index_now.key = VALID_KEY
73
97
 
74
98
  indexnow_url = "https://api.indexnow.org/indexnow"
75
99
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
76
100
 
77
- Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
101
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
78
102
 
79
103
  assert_requested(:post, indexnow_url, times: 1) do |request|
80
104
  payload = JSON.parse(request.body)
@@ -82,7 +106,11 @@ class IndexmapPingerIndexNowTest < Minitest::Test
82
106
  "https://www.example.com/pages/features",
83
107
  "https://www.example.com/insights/us/restaurants/overview"
84
108
  ].sort, payload.fetch("urlList").sort
109
+ assert_equal "https://www.example.com/#{VALID_KEY}.txt", payload.fetch("keyLocation")
85
110
  end
111
+ assert_equal :submitted, result[:status]
112
+ assert_equal 2, result[:url_count]
113
+ assert_equal 1, result[:batch_count]
86
114
  end
87
115
  end
88
116
 
@@ -98,13 +126,17 @@ class IndexmapPingerIndexNowTest < Minitest::Test
98
126
  configuration = Indexmap::Configuration.new
99
127
  configuration.base_url = "https://www.example.com"
100
128
  configuration.public_path = public_path
101
- configuration.index_now.key = "test-key"
129
+ configuration.index_now.key = VALID_KEY
102
130
 
103
131
  indexnow_url = "https://api.indexnow.org/indexnow"
104
132
  stub_request(:post, indexnow_url).to_return(status: 200, body: "", headers: {})
105
133
 
106
134
  with_env("SINCE" => "2026-04-15T00:00:00Z") do
107
- Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
135
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
136
+
137
+ assert_equal :submitted, result[:status]
138
+ assert_equal 1, result[:url_count]
139
+ assert_equal 1, result[:batch_count]
108
140
  end
109
141
 
110
142
  assert_requested(:post, indexnow_url, times: 1) do |request|
@@ -114,6 +146,105 @@ class IndexmapPingerIndexNowTest < Minitest::Test
114
146
  end
115
147
  end
116
148
 
149
+ def test_skips_indexnow_ping_when_key_is_missing
150
+ Dir.mktmpdir do |dir|
151
+ public_path = Pathname(dir)
152
+ write_sitemap_files(
153
+ public_path,
154
+ marketing_lastmod: "2026-04-18T00:00:00Z",
155
+ insights_lastmod: "2026-04-10T00:00:00Z"
156
+ )
157
+
158
+ configuration = Indexmap::Configuration.new
159
+ configuration.base_url = "https://www.example.com"
160
+ configuration.public_path = public_path
161
+
162
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
163
+
164
+ assert_equal({status: :skipped, reason: :missing_key}, result)
165
+ end
166
+ end
167
+
168
+ def test_reports_indexnow_dry_run
169
+ Dir.mktmpdir do |dir|
170
+ public_path = Pathname(dir)
171
+ write_sitemap_files(
172
+ public_path,
173
+ marketing_lastmod: "2026-04-18T00:00:00Z",
174
+ insights_lastmod: "2026-04-10T00:00:00Z"
175
+ )
176
+
177
+ configuration = Indexmap::Configuration.new
178
+ configuration.base_url = "https://www.example.com"
179
+ configuration.public_path = public_path
180
+ configuration.index_now.key = VALID_KEY
181
+
182
+ with_env("INDEXNOW_DRY_RUN" => "1") do
183
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
184
+
185
+ assert_equal :dry_run, result[:status]
186
+ assert_equal 2, result[:url_count]
187
+ assert_equal 1, result[:batch_count]
188
+ end
189
+ end
190
+ end
191
+
192
+ def test_reports_failed_indexnow_submission
193
+ Dir.mktmpdir do |dir|
194
+ public_path = Pathname(dir)
195
+ write_sitemap_files(
196
+ public_path,
197
+ marketing_lastmod: "2026-04-18T00:00:00Z",
198
+ insights_lastmod: "2026-04-10T00:00:00Z"
199
+ )
200
+
201
+ configuration = Indexmap::Configuration.new
202
+ configuration.base_url = "https://www.example.com"
203
+ configuration.public_path = public_path
204
+ configuration.index_now.key = VALID_KEY
205
+
206
+ indexnow_url = "https://api.indexnow.org/indexnow"
207
+ stub_request(:post, indexnow_url).to_return(status: 500, body: "boom", headers: {})
208
+
209
+ result = Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
210
+
211
+ assert_equal :failed, result[:status]
212
+ assert_equal 1, result[:failures].count
213
+ assert_equal 500, result[:failures].first[:status_code]
214
+ end
215
+ end
216
+
217
+ def test_rejects_invalid_configured_key
218
+ Dir.mktmpdir do |dir|
219
+ configuration = Indexmap::Configuration.new
220
+ configuration.base_url = "https://www.example.com"
221
+ configuration.public_path = Pathname(dir)
222
+ configuration.index_now.key = "test-key"
223
+
224
+ error = assert_raises(Indexmap::ConfigurationError) do
225
+ Indexmap::Pinger::IndexNow.new(configuration: configuration).ping
226
+ end
227
+
228
+ assert_equal "IndexNow key must be a 32-character lowercase hexadecimal string", error.message
229
+ end
230
+ end
231
+
232
+ def test_reuses_existing_key_file_deterministically
233
+ Dir.mktmpdir do |dir|
234
+ public_path = Pathname(dir)
235
+ public_path.join("ffffffffffffffffffffffffffffffff.txt").write("ffffffffffffffffffffffffffffffff")
236
+ public_path.join("00000000000000000000000000000000.txt").write("00000000000000000000000000000000")
237
+
238
+ configuration = Indexmap::Configuration.new
239
+ configuration.base_url = "https://www.example.com"
240
+ configuration.public_path = public_path
241
+
242
+ path = Indexmap::Pinger::IndexNow.new(configuration: configuration).ensure_key_file
243
+
244
+ assert_equal public_path.join("00000000000000000000000000000000.txt"), path
245
+ end
246
+ end
247
+
117
248
  private
118
249
 
119
250
  def with_env(overrides)
@@ -3,6 +3,8 @@
3
3
  require "test_helper"
4
4
 
5
5
  class IndexmapTaskRunnerTest < Minitest::Test
6
+ VALID_KEY = "1234567890abcdef1234567890abcdef"
7
+
6
8
  def test_create_removes_existing_sitemap_files_writes_new_sitemap_and_key_file
7
9
  Dir.mktmpdir do |dir|
8
10
  public_path = Pathname(dir)
@@ -18,15 +20,15 @@ class IndexmapTaskRunnerTest < Minitest::Test
18
20
  entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
19
21
  )
20
22
  ]
21
- configuration.index_now.key = "test-key"
23
+ configuration.index_now.key = VALID_KEY
22
24
 
23
25
  result = Indexmap::TaskRunner.new(configuration: configuration).create
24
26
 
25
27
  assert_equal false, public_path.join("sitemap-pages.xml.gz").exist?
26
28
  assert_includes public_path.join("sitemap.xml").read, "<sitemapindex"
27
- assert_equal "test-key\n", public_path.join("test-key.txt").read
29
+ assert_equal VALID_KEY, public_path.join("#{VALID_KEY}.txt").read
28
30
  assert_equal [public_path.join("sitemap-pages.xml").to_s, public_path.join("sitemap.xml").to_s], result[:files]
29
- assert_equal public_path.join("test-key.txt"), result[:index_now_key_path]
31
+ assert_equal public_path.join("#{VALID_KEY}.txt"), result[:index_now_key_path]
30
32
  end
31
33
  end
32
34
 
@@ -56,8 +58,8 @@ class IndexmapTaskRunnerTest < Minitest::Test
56
58
 
57
59
  result = Indexmap::TaskRunner.new(configuration: configuration).write_index_now_key(generate_if_missing: true)
58
60
 
59
- assert_match(/\A[a-z0-9-]{8,128}\.txt\z/, result.basename.to_s)
60
- assert_equal "#{result.basename(".txt")}\n", result.read
61
+ assert_match(/\A[a-f0-9]{32}\.txt\z/, result.basename.to_s)
62
+ assert_equal result.basename(".txt").to_s, result.read
61
63
  end
62
64
  end
63
65
  end
@@ -77,4 +77,23 @@ class IndexmapWriterTest < Minitest::Test
77
77
  refute File.exist?(File.join(directory, "sitemap-pages.xml"))
78
78
  end
79
79
  end
80
+
81
+ def test_omits_sitemap_index_lastmod_when_sections_have_no_lastmod
82
+ Dir.mktmpdir do |directory|
83
+ Indexmap::Writer.new(
84
+ sections: [
85
+ Indexmap::Section.new(
86
+ filename: "sitemap-pages.xml",
87
+ entries: [Indexmap::Entry.new(loc: "https://example.com/about")]
88
+ )
89
+ ],
90
+ public_path: directory,
91
+ base_url: "https://example.com"
92
+ ).write
93
+
94
+ index_xml = File.read(File.join(directory, "sitemap.xml"))
95
+
96
+ refute_includes index_xml, "<lastmod>"
97
+ end
98
+ end
80
99
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: indexmap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Fidalgo