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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "timeout"
5
+
6
+ class IndexmapPublicationTest < Minitest::Test
7
+ def configuration(directory, atomic: true)
8
+ Indexmap::Configuration.new.tap do |config|
9
+ config.base_url = "https://example.com"
10
+ config.storage = Indexmap::Storage::Filesystem.new(path: directory, public_url: config.base_url)
11
+ config.atomic = atomic
12
+ config.max_urls = 2
13
+ config.sections = -> do
14
+ entries = (1..5).lazy.map { |id| Indexmap::Entry.new(loc: "https://example.com/#{id}") }
15
+ [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: entries)]
16
+ end
17
+ end
18
+ end
19
+
20
+ def test_partitions_once_and_publishes_immutable_children
21
+ Dir.mktmpdir do |directory|
22
+ config = configuration(directory)
23
+ result = Indexmap::TaskRunner.new(configuration: config).create
24
+ assert_equal [2, 2, 1, 3], result.fetch(:sitemaps).map { |file| file.fetch(:link_count) }
25
+ old_files = result.fetch(:files)
26
+ bodies = old_files.to_h { |file| [file, config.storage.read(file)] }
27
+ assert_raises(Indexmap::ConfigurationError) { config.storage.write(old_files.first, "overwrite") }
28
+ assert_raises(Indexmap::ConfigurationError) { Indexmap::TaskRunner.new(configuration: config).format }
29
+ second = Indexmap::TaskRunner.new(configuration: config).create
30
+ refute_equal old_files, second.fetch(:files)
31
+ old_files.reject { |file| file == "sitemap.xml" }.each { |file| assert_equal bodies[file], config.storage.read(file) }
32
+ publication = Indexmap::Publication.new(root: "sitemap.xml")
33
+ assert config.storage.exist?(publication.archive(publication.generation(old_files.first)))
34
+ assert Indexmap::Validator.new(configuration: config).validate!
35
+ end
36
+ end
37
+
38
+ def test_byte_partitioning_counts_escaped_output
39
+ Dir.mktmpdir do |directory|
40
+ config = configuration(directory, atomic: false)
41
+ config.max_urls = 50_000
42
+ config.max_bytes = 400
43
+ config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: (1..3).map do |id|
44
+ Indexmap::Entry.new(loc: "https://example.com/#{id}#{"a" * 100}&b")
45
+ end)]
46
+ files = Indexmap.create(configuration: config)
47
+ assert_operator files.size, :>, 2
48
+ files.each { |file| assert_operator config.storage.metadata(file).fetch(:bytes), :<=, 400 }
49
+ end
50
+ end
51
+
52
+ def test_failed_copy_does_not_change_published_files_or_leave_staging
53
+ Dir.mktmpdir do |directory|
54
+ config = configuration(directory)
55
+ Indexmap.create(configuration: config)
56
+ storage = config.storage
57
+ previous = storage.list.to_h { |filename| [filename, storage.read(filename)] }
58
+ storage.define_singleton_method(:publish) do |source, **options|
59
+ calls = 0
60
+ source.define_singleton_method(:open) do |filename, &block|
61
+ calls += 1
62
+ raise IOError, "interrupted copy" if calls == 2
63
+
64
+ super(filename, &block)
65
+ end
66
+ super(source, **options)
67
+ end
68
+ assert_raises(IOError) { Indexmap.create(configuration: config) }
69
+ assert_equal previous, storage.list.to_h { |filename| [filename, storage.read(filename)] }
70
+ assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
71
+ end
72
+ end
73
+
74
+ def test_atomic_refresh_preserves_existing_root_permissions
75
+ Dir.mktmpdir do |directory|
76
+ config = configuration(directory)
77
+ Indexmap.create(configuration: config)
78
+ root = File.join(directory, "sitemap.xml")
79
+ File.chmod(0o640, root)
80
+ Indexmap.create(configuration: config)
81
+ assert_equal 0o640, File.stat(root).mode & 0o777
82
+ assert Indexmap::Validator.new(configuration: config).validate!
83
+ end
84
+ end
85
+
86
+ def test_failed_root_activation_removes_new_children
87
+ Dir.mktmpdir do |directory|
88
+ config = configuration(directory)
89
+ old = Indexmap.create(configuration: config)
90
+ previous = config.storage.read("sitemap.xml")
91
+ rename = File.method(:rename)
92
+ File.stub(:rename, ->(source, destination) do
93
+ raise Errno::EACCES, "root activation denied" if destination.to_s == File.join(directory, "sitemap.xml")
94
+
95
+ rename.call(source, destination)
96
+ end) do
97
+ assert_raises(Errno::EACCES) { Indexmap.create(configuration: config) }
98
+ end
99
+ assert_equal previous, config.storage.read("sitemap.xml")
100
+ children = config.storage.list(prefix: "sitemap-", suffix: ".xml").reject { |name| name.end_with?("-index.xml") }
101
+ assert_equal (old - ["sitemap.xml"]).sort, children
102
+ assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
103
+ assert Indexmap::Validator.new(configuration: config).validate!
104
+ end
105
+ end
106
+
107
+ def test_copying_does_not_block_a_competing_publisher
108
+ Dir.mktmpdir do |directory|
109
+ config = configuration(directory)
110
+ Indexmap.create(configuration: config)
111
+ storage = config.storage
112
+ entered = Queue.new
113
+ release = Queue.new
114
+ storage.define_singleton_method(:publish) do |source, **options|
115
+ source.define_singleton_method(:open) do |filename, &block|
116
+ unless @paused
117
+ @paused = true
118
+ entered << true
119
+ release.pop
120
+ end
121
+ super(filename, &block)
122
+ end
123
+ super(source, **options)
124
+ end
125
+ thread = Thread.new do
126
+ Indexmap.create(configuration: config)
127
+ rescue Indexmap::SupersededPublication => error
128
+ error
129
+ end
130
+ Timeout.timeout(5) { entered.pop }
131
+ competing = configuration(directory)
132
+ winner = Timeout.timeout(5) { Indexmap.create(configuration: competing) }
133
+ release << true
134
+ assert_instance_of Indexmap::SupersededPublication, Timeout.timeout(5) { thread.value }
135
+ assert winner.all? { |filename| storage.exist?(filename) }
136
+ assert Indexmap::Parser.new(storage: storage).entries.all? { |entry| winner.include?(entry.source_sitemap) }
137
+ assert Indexmap::Validator.new(configuration: config).validate!
138
+ assert_empty Dir.glob(File.join(directory, ".indexmap-builds/*/build-*"))
139
+ ensure
140
+ thread&.kill if thread&.alive?
141
+ end
142
+ end
143
+
144
+ def test_pruning_reclaims_crashed_staging_and_skips_live_builds
145
+ Dir.mktmpdir do |directory|
146
+ config = configuration(directory)
147
+ Indexmap.create(configuration: config)
148
+ reader, writer = IO.pipe
149
+ pid = fork do
150
+ reader.close
151
+ config.storage.define_singleton_method(:publish) do |source, **options|
152
+ source.define_singleton_method(:open) do |filename, &block|
153
+ writer.write("staging")
154
+ writer.flush
155
+ sleep
156
+ super(filename, &block)
157
+ end
158
+ super(source, **options)
159
+ end
160
+ Indexmap.create(configuration: config)
161
+ end
162
+ writer.close
163
+ assert IO.select([reader], nil, nil, 5), "build did not reach destination staging"
164
+ reader.read(7)
165
+ staged = Dir.glob(File.join(directory, ".indexmap-builds/*/build-*")).fetch(0)
166
+ File.utime(Time.at(0), Time.at(0), staged)
167
+ assert_empty config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
168
+ assert File.directory?(staged)
169
+ Process.kill("KILL", pid)
170
+ Process.wait(pid)
171
+ pid = nil
172
+ deleted = config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
173
+ assert_equal [Pathname(staged).relative_path_from(Pathname(directory)).to_s], deleted
174
+ refute File.exist?(staged)
175
+ assert Indexmap::Validator.new(configuration: config).validate!
176
+ ensure
177
+ if pid
178
+ Process.kill("KILL", pid)
179
+ Process.wait(pid)
180
+ end
181
+ reader&.close
182
+ writer&.close unless writer&.closed?
183
+ end
184
+ end
185
+
186
+ def test_rejects_stale_builds_and_keeps_other_outputs_during_cleanup
187
+ Dir.mktmpdir do |directory|
188
+ config = configuration(directory)
189
+ old = Indexmap.create(configuration: config)
190
+ config.output(:other) do |output|
191
+ output.index_filename = "other.xml"
192
+ output.sections = -> { [Indexmap::Section.new(filename: "sitemap-other.xml", entries: [{loc: "https://example.com/other"}])] }
193
+ end
194
+ other = Indexmap.create(:other, configuration: config)
195
+ expected = config.storage.revision("sitemap.xml")
196
+ Indexmap.create(configuration: config)
197
+ assert_raises(Indexmap::SupersededPublication) do
198
+ config.storage.publish(nil, files: [], root: "sitemap.xml", expected: expected)
199
+ end
200
+ config.storage.list(suffix: ".xml").each { |name| File.utime(Time.at(0), Time.at(0), File.join(directory, name)) }
201
+ deleted = config.storage.prune(root: "sitemap.xml", before: Time.now.utc - 3600)
202
+ assert (old - ["sitemap.xml"]).all? { |file| deleted.include?(file) }
203
+ assert other.all? { |file| config.storage.exist?(file) }
204
+ end
205
+ end
206
+
207
+ def test_duplicates_across_partitions_fail_before_publication
208
+ Dir.mktmpdir do |directory|
209
+ config = configuration(directory)
210
+ Indexmap.create(configuration: config)
211
+ previous = config.storage.read("sitemap.xml")
212
+ config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [
213
+ {loc: "https://example.com/a"}, {loc: "https://example.com/b"}, {loc: "https://example.com/a"}
214
+ ])]
215
+ assert_raises(Indexmap::ValidationError) { Indexmap.create(configuration: config) }
216
+ assert_equal previous, config.storage.read("sitemap.xml")
217
+ end
218
+ end
219
+
220
+ def test_filesystem_rejects_symlinks_escaping_storage
221
+ Dir.mktmpdir do |directory|
222
+ Dir.mktmpdir do |outside|
223
+ storage = Indexmap::Storage::Filesystem.new(path: directory)
224
+ File.symlink(outside, File.join(directory, "escape"))
225
+ assert_raises(ArgumentError) { storage.write("escape/private.xml", "private") }
226
+ end
227
+ end
228
+ end
229
+
230
+ def test_concurrent_builds_cannot_overwrite_a_newer_root
231
+ Dir.mktmpdir do |directory|
232
+ config = configuration(directory)
233
+ entered = Queue.new
234
+ release = Queue.new
235
+ config.sections = -> do
236
+ source = Enumerator.new do |stream|
237
+ entered << true
238
+ release.pop
239
+ stream << Indexmap::Entry.new(loc: "https://example.com/page")
240
+ end
241
+ [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: source)]
242
+ end
243
+ threads = 2.times.map do
244
+ Thread.new do
245
+ Indexmap.create(configuration: config)
246
+ rescue Indexmap::SupersededPublication => error
247
+ error
248
+ end
249
+ end
250
+ Timeout.timeout(5) { 2.times { entered.pop } }
251
+ 2.times { release << true }
252
+ results = Timeout.timeout(5) { threads.map(&:value) }
253
+ assert_equal 1, results.count { |result| result.is_a?(Indexmap::SupersededPublication) }
254
+ assert Indexmap::Validator.new(configuration: config).validate!
255
+ ensure
256
+ threads&.each { |thread| thread.kill if thread.alive? }
257
+ end
258
+ end
259
+
260
+ def test_atomic_mode_requires_an_explicit_adapter_capability
261
+ config = Indexmap::Configuration.new
262
+ config.atomic = true
263
+ config.storage = Indexmap::Storage::Memory.new
264
+ config.sections = -> { raise "must not evaluate an unsupported build" }
265
+ assert_raises(Indexmap::ConfigurationError) { Indexmap.create(configuration: config) }
266
+ end
267
+
268
+ def test_duplicates_across_sort_runs_fail_before_publication
269
+ Dir.mktmpdir do |directory|
270
+ config = configuration(directory)
271
+ Indexmap.create(configuration: config)
272
+ previous = config.storage.read("sitemap.xml")
273
+ config.max_urls = 5000
274
+ entries = (0..10_000).lazy.map { |id| {loc: "https://example.com/#{id % 10_000}"} }
275
+ config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: entries)]
276
+ assert_raises(Indexmap::ValidationError) { Indexmap.create(configuration: config) }
277
+ assert_equal previous, config.storage.read("sitemap.xml")
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "indexmap/robots_controller"
5
+ require "rack/mock"
6
+
7
+ class IndexmapRobotsTest < Minitest::Test
8
+ def setup
9
+ Indexmap.reset!
10
+ Indexmap.configuration.base_url = "https://example.com"
11
+ end
12
+
13
+ def teardown
14
+ Indexmap.reset!
15
+ end
16
+
17
+ def test_defaults_to_disallow_without_resolving_sitemap_data
18
+ Indexmap.configuration.base_url = -> { raise "must not resolve" }
19
+ Indexmap.configuration.sections = -> { raise "must not query" }
20
+ Indexmap.configuration.storage = -> { raise "must not read storage" }
21
+ assert_equal "User-agent: *\nDisallow: /\n", Indexmap.configuration.robots.body
22
+ end
23
+
24
+ def test_uses_explicit_indexability_and_the_configured_root
25
+ indexable = true
26
+ config = Indexmap.configuration
27
+ config.index_filename = "sitemaps/main.xml"
28
+ config.robots.indexable = -> { indexable }
29
+ config.robots.groups = [{user_agent: ["*", "ExampleBot"], allow: ["/"], disallow: ["/admin/", "/search"]}]
30
+ assert_includes config.robots.body, "Sitemap: https://example.com/sitemaps/main.xml"
31
+ assert_includes config.robots.body, "Disallow: /admin/"
32
+ assert_includes config.robots.body, "User-agent: ExampleBot"
33
+ indexable = false
34
+ assert_equal "User-agent: *\nDisallow: /\n", config.robots.body
35
+ end
36
+
37
+ def test_rejects_injected_directives_and_invalid_sitemap_urls
38
+ config = Indexmap.configuration
39
+ config.robots.indexable = true
40
+ config.robots.groups = [{user_agent: "*", disallow: ["/admin/\nAllow: /"]}]
41
+ assert_raises(Indexmap::ConfigurationError) { config.robots.body }
42
+ config.robots.groups = [{user_agent: "*"}]
43
+ ["/sitemap.xml", "https://user:password@example.com/sitemap.xml", "https://example.com/sitemap.xml\nDisallow: /", "file:///sitemap.xml"].each do |url|
44
+ config.robots.sitemaps = [url]
45
+ assert_raises(Indexmap::ConfigurationError) { config.robots.body }
46
+ end
47
+ end
48
+
49
+ def test_opt_in_route_serves_get_head_and_conditional_responses_without_cookies
50
+ Indexmap.configuration.robots.indexable = true
51
+ Indexmap.configuration.sections = -> { raise "must not query content" }
52
+ routes = ActionDispatch::Routing::RouteSet.new
53
+ routes.draw { get "/robots.txt", to: Indexmap::RobotsController.action(:show) }
54
+ client = Rack::MockRequest.new(Rack::Head.new(routes))
55
+ response = client.get("/robots.txt", "HTTP_HOST" => "attacker.example")
56
+ assert_equal 200, response.status
57
+ assert_match "text/plain", response["content-type"]
58
+ assert_includes response.body, "Sitemap: https://example.com/sitemap.xml"
59
+ assert_includes response["cache-control"], "max-age=3600"
60
+ assert_nil response["set-cookie"]
61
+ head = client.head("/robots.txt")
62
+ assert_equal 200, head.status
63
+ assert_empty head.body
64
+ cached = client.get("/robots.txt", "HTTP_IF_NONE_MATCH" => response["etag"])
65
+ assert_equal 304, cached.status
66
+ assert_empty cached.body
67
+ assert_equal 404, client.post("/robots.txt").status
68
+ Indexmap.configuration.robots.indexable = false
69
+ disabled = client.get("/robots.txt", "HTTP_IF_NONE_MATCH" => response["etag"])
70
+ assert_equal 200, disabled.status
71
+ assert_equal "User-agent: *\nDisallow: /\n", disabled.body
72
+ end
73
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "indexmap/sitemaps_controller"
5
+ require "rack/mock"
6
+ require "active_support/cache"
7
+
8
+ class IndexmapSitemapsControllerTest < Minitest::Test
9
+ def test_serves_only_published_and_retained_artifacts_without_source_queries
10
+ Dir.mktmpdir do |directory|
11
+ Indexmap.reset!
12
+ Rails.cache = ActiveSupport::Cache::MemoryStore.new
13
+ config = Indexmap.configuration
14
+ config.base_url = "https://example.com"
15
+ config.storage = Indexmap::Storage::Filesystem.new(path: directory)
16
+ config.atomic = true
17
+ config.sections = -> { [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [{loc: "https://example.com/page"}])] }
18
+ old = Indexmap.create.first
19
+ child = Indexmap.create.first
20
+ unpublished = Indexmap::Publication.new(root: "sitemap.xml").filename(generation: "f" * 32, section: 1, part: 1)
21
+ config.storage.write(unpublished, "not published")
22
+ config.sections = -> { raise "must not query source content" }
23
+ routes = ActionDispatch::Routing::RouteSet.new
24
+ routes.draw do
25
+ get "/sitemap.xml", to: Indexmap::SitemapsController.action(:show), defaults: {filename: "sitemap.xml"}, format: false
26
+ get "/:filename", to: Indexmap::SitemapsController.action(:show), constraints: {filename: /[^\/]+/}, format: false
27
+ end
28
+ client = Rack::MockRequest.new(Rack::Head.new(routes))
29
+ response = client.get("/#{child}")
30
+ assert_equal 200, response.status
31
+ assert_includes response.body, "https://example.com/page"
32
+ assert_match "application/xml", response["content-type"]
33
+ assert_includes response["cache-control"], "immutable"
34
+ assert_nil response["set-cookie"]
35
+ assert_equal 200, client.get("/#{old}").status
36
+ assert_equal 404, client.get("/#{unpublished}").status
37
+ assert_equal 404, client.get("/robots.txt").status
38
+ assert_equal 200, client.get("/#{child}?prefix=../../").status
39
+ config.storage.define_singleton_method(:open) { |_filename, &_block| raise "must not download body" }
40
+ cached = client.get("/#{child}", "HTTP_IF_NONE_MATCH" => response["etag"])
41
+ assert_equal 304, cached.status
42
+ assert_empty cached.body
43
+ head = client.head("/#{child}")
44
+ assert_equal 200, head.status
45
+ assert_empty head.body
46
+ ensure
47
+ Indexmap.reset!
48
+ end
49
+ end
50
+ end
@@ -94,6 +94,33 @@ class IndexmapStorageTest < Minitest::Test
94
94
  end
95
95
  end
96
96
 
97
+ def test_filesystem_listing_scopes_literal_prefixes_before_inspecting_files
98
+ Dir.mktmpdir do |directory|
99
+ storage = Indexmap::Storage::Filesystem.new(path: directory)
100
+ storage.write("sitemaps/current-1.xml", "xml")
101
+ storage.write("sitemaps/current-2.xml", "xml")
102
+ storage.write("sitemaps/other/old.xml", "xml")
103
+ storage.write("uploads/unrelated.xml", "xml")
104
+ storage.write("sitemaps/literal[1].xml", "xml")
105
+ file_test = FileTest.method(:file?)
106
+ FileTest.stub(:file?, ->(name) do
107
+ raise "inspected unrelated files" if name.to_s.include?("uploads/") || name.to_s.include?("sitemaps/other/")
108
+
109
+ file_test.call(name)
110
+ end) do
111
+ assert_equal ["sitemaps/current-1.xml", "sitemaps/current-2.xml"], storage.list(prefix: "sitemaps/current-", suffix: ".xml")
112
+ end
113
+ assert_equal ["sitemaps/literal[1].xml"], storage.list(prefix: "sitemaps/literal[", suffix: ".xml")
114
+ assert_raises(ArgumentError) { storage.list(prefix: "../outside/") }
115
+ assert_raises(ArgumentError) { storage.list(prefix: "/sitemaps/") }
116
+ assert_raises(ArgumentError) { storage.list(prefix: "sitemaps/../uploads/") }
117
+ storage.write(".private/hidden.xml", "private")
118
+ File.symlink(File.join(directory, "sitemaps"), File.join(directory, "alias"))
119
+ assert_empty storage.list(prefix: ".private/")
120
+ assert_empty storage.list(prefix: "alias/")
121
+ end
122
+ end
123
+
97
124
  def test_filesystem_storage_rejects_absolute_filenames
98
125
  Dir.mktmpdir do |dir|
99
126
  storage = Indexmap::Storage::Filesystem.new(path: dir)
@@ -116,6 +116,30 @@ class IndexmapTaskRunnerTest < Minitest::Test
116
116
  assert_includes storage.read("sitemap.xml"), "\n <url>"
117
117
  end
118
118
 
119
+ def test_generation_reports_do_not_download_published_artifacts
120
+ storage = Class.new(Indexmap::Storage::Memory) do
121
+ def read(_filename)
122
+ raise "must not download published artifacts for counts"
123
+ end
124
+ end.new
125
+ result = Indexmap::TaskRunner.new(configuration: configuration_with(storage: storage)).create
126
+ assert_equal [1, 1], result.fetch(:sitemaps).map { |file| file.fetch(:link_count) }
127
+ end
128
+
129
+ def test_create_resolves_storage_once_for_publication_and_key_handling
130
+ stores = []
131
+ config = configuration_with
132
+ config.storage = -> { stores.push(Indexmap::Storage::Memory.new).last }
133
+ config.index_now.key = VALID_KEY
134
+ result = Indexmap::TaskRunner.new(configuration: config).create
135
+ assert_equal 1, stores.size
136
+ assert result.fetch(:files).all? { |filename| stores.first.exist?(filename) }
137
+ assert_equal VALID_KEY, stores.first.read("#{VALID_KEY}.txt")
138
+ Indexmap::TaskRunner.new(configuration: config).create
139
+ assert_equal 2, stores.size
140
+ assert stores.last.exist?("sitemap.xml")
141
+ end
142
+
119
143
  private
120
144
 
121
145
  def configuration_with(storage: Indexmap::Storage::Memory.new)
@@ -167,6 +167,20 @@ class IndexmapValidatorTest < Minitest::Test
167
167
  XML
168
168
  end
169
169
 
170
+ def test_validation_keeps_one_resolved_storage_for_the_entire_graph
171
+ config = configuration_with
172
+ config.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: [{loc: "https://example.com/"}])]
173
+ Indexmap.create(configuration: config)
174
+ storage = config.storage
175
+ calls = 0
176
+ config.storage = -> {
177
+ calls += 1
178
+ storage
179
+ }
180
+ assert Indexmap::Validator.new(configuration: config).validate!(collect: false)
181
+ assert_equal 1, calls
182
+ end
183
+
170
184
  private
171
185
 
172
186
  def validate_sitemap(body)
@@ -85,4 +85,46 @@ class IndexmapWriterTest < Minitest::Test
85
85
 
86
86
  refute_includes index_xml, "<lastmod>"
87
87
  end
88
+
89
+ def test_consumes_section_entries_once_and_preserves_output_and_latest_date
90
+ entries = [
91
+ {loc: "https://example.com/first", lastmod: Date.new(2026, 4, 21)},
92
+ {loc: "https://example.com/second", lastmod: "2026-04-22T09:15:00Z"},
93
+ {loc: "https://example.com/third", lastmod: nil}
94
+ ]
95
+ iterations = 0
96
+ source = Enumerator.new do |yielder|
97
+ iterations += 1
98
+ raise "source was enumerated twice" if iterations > 1
99
+
100
+ entries.each { |entry| yielder << entry }
101
+ end
102
+ expected = Indexmap::Writer.new(base_url: "https://example.com",
103
+ sections: [{filename: "sitemap-pages.xml", entries: entries}]).write
104
+ actual = Indexmap::Writer.new(base_url: "https://example.com",
105
+ sections: [{filename: "sitemap-pages.xml", entries: source}]).write
106
+
107
+ assert_equal expected.map(&:body), actual.map(&:body)
108
+ assert_equal 1, iterations
109
+ assert_includes actual.last.body, "<lastmod>2026-04-22T09:15:00Z</lastmod>"
110
+ end
111
+
112
+ def test_create_publishes_a_one_shot_section_source
113
+ configuration = Indexmap::Configuration.new
114
+ configuration.base_url = "https://example.com"
115
+ configuration.storage = Indexmap::Storage::Memory.new
116
+ iterations = 0
117
+ source = Enumerator.new do |yielder|
118
+ iterations += 1
119
+ raise "source was enumerated twice" if iterations > 1
120
+
121
+ yielder << Indexmap::Entry.new(loc: "https://example.com/page", lastmod: Time.utc(2026, 4, 21))
122
+ end
123
+ configuration.sections = [Indexmap::Section.new(filename: "sitemap-pages.xml", entries: source)]
124
+
125
+ assert_equal ["sitemap-pages.xml", "sitemap.xml"], Indexmap.create(configuration: configuration)
126
+ assert_equal 1, iterations
127
+ assert_includes configuration.storage.read("sitemap.xml"), "<lastmod>2026-04-21T00:00:00Z</lastmod>"
128
+ assert_includes configuration.storage.read("sitemap-pages.xml"), "<loc>https://example.com/page</loc>"
129
+ end
88
130
  end