ruby-link-checker 0.3.0 → 0.5.1

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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/coverage.yml +24 -0
  3. data/.github/workflows/danger-comment.yml +17 -0
  4. data/.github/workflows/danger.yml +12 -0
  5. data/.github/workflows/lint.yml +20 -0
  6. data/.github/workflows/release.yml +14 -0
  7. data/.github/workflows/test.yml +25 -0
  8. data/.gitignore +6 -0
  9. data/.rspec +2 -0
  10. data/.rubocop.yml +26 -0
  11. data/.rubocop_todo.yml +332 -0
  12. data/AGENTS.md +23 -0
  13. data/CHANGELOG.md +27 -0
  14. data/CONTRIBUTING.md +4 -0
  15. data/Dangerfile +2 -0
  16. data/Gemfile +5 -1
  17. data/README.md +151 -7
  18. data/RELEASING.md +17 -40
  19. data/Rakefile +8 -0
  20. data/benchmarks/check.rb +27 -20
  21. data/lib/ruby-link-checker/async/http/checker.rb +118 -0
  22. data/lib/ruby-link-checker/async/http/config.rb +39 -0
  23. data/lib/ruby-link-checker/async/http/result.rb +85 -0
  24. data/lib/ruby-link-checker/async/http.rb +3 -0
  25. data/lib/ruby-link-checker/cache.rb +101 -0
  26. data/lib/ruby-link-checker/callbacks.rb +2 -2
  27. data/lib/ruby-link-checker/checker.rb +76 -4
  28. data/lib/ruby-link-checker/config.rb +64 -0
  29. data/lib/ruby-link-checker/errors/connection_error.rb +23 -0
  30. data/lib/ruby-link-checker/errors/redirect_limit_error.rb +15 -0
  31. data/lib/ruby-link-checker/errors.rb +2 -0
  32. data/lib/ruby-link-checker/net/http/checker.rb +4 -1
  33. data/lib/ruby-link-checker/net/http/result.rb +4 -0
  34. data/lib/ruby-link-checker/result.rb +25 -0
  35. data/lib/ruby-link-checker/task.rb +2 -0
  36. data/lib/ruby-link-checker/tasks.rb +43 -7
  37. data/lib/ruby-link-checker/typhoeus/hydra/checker.rb +29 -6
  38. data/lib/ruby-link-checker/typhoeus/hydra/result.rb +4 -0
  39. data/lib/ruby-link-checker/version.rb +1 -1
  40. data/lib/ruby-link-checker.rb +2 -0
  41. data/ruby-link-checker.gemspec +3 -2
  42. data/spec/ruby-link-checker/async/http/checker_spec.rb +184 -0
  43. data/spec/ruby-link-checker/callbacks_spec.rb +33 -0
  44. data/spec/ruby-link-checker/checker_spec.rb +89 -2
  45. data/spec/ruby-link-checker/config_spec.rb +6 -0
  46. data/spec/ruby-link-checker/errors/redirect_loop_error_spec.rb +18 -0
  47. data/spec/ruby-link-checker/net/http/checker_spec.rb +32 -0
  48. data/spec/ruby-link-checker/result_error_spec.rb +34 -0
  49. data/spec/ruby-link-checker/result_spec.rb +41 -0
  50. data/spec/ruby-link-checker/task_spec.rb +20 -0
  51. data/spec/ruby-link-checker/tasks_spec.rb +91 -0
  52. data/spec/ruby-link-checker/typhoeus/hydra/checker_spec.rb +116 -3
  53. data/spec/spec_helper.rb +3 -1
  54. data/spec/support/link_checker.rb +267 -3
  55. metadata +42 -35
  56. data/Gemfile.lock +0 -215
  57. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc.png +0 -0
  58. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_asc_disabled.png +0 -0
  59. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_both.png +0 -0
  60. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc.png +0 -0
  61. data/coverage/assets/0.13.1/DataTables-1.10.20/images/sort_desc_disabled.png +0 -0
  62. data/coverage/assets/0.13.1/application.css +0 -1
  63. data/coverage/assets/0.13.1/application.js +0 -7
  64. data/coverage/assets/0.13.1/colorbox/border.png +0 -0
  65. data/coverage/assets/0.13.1/colorbox/controls.png +0 -0
  66. data/coverage/assets/0.13.1/colorbox/loading.gif +0 -0
  67. data/coverage/assets/0.13.1/colorbox/loading_background.png +0 -0
  68. data/coverage/assets/0.13.1/favicon_green.png +0 -0
  69. data/coverage/assets/0.13.1/favicon_red.png +0 -0
  70. data/coverage/assets/0.13.1/favicon_yellow.png +0 -0
  71. data/coverage/assets/0.13.1/images/ui-bg_flat_0_aaaaaa_40x100.png +0 -0
  72. data/coverage/assets/0.13.1/images/ui-bg_flat_75_ffffff_40x100.png +0 -0
  73. data/coverage/assets/0.13.1/images/ui-bg_glass_55_fbf9ee_1x400.png +0 -0
  74. data/coverage/assets/0.13.1/images/ui-bg_glass_65_ffffff_1x400.png +0 -0
  75. data/coverage/assets/0.13.1/images/ui-bg_glass_75_dadada_1x400.png +0 -0
  76. data/coverage/assets/0.13.1/images/ui-bg_glass_75_e6e6e6_1x400.png +0 -0
  77. data/coverage/assets/0.13.1/images/ui-bg_glass_95_fef1ec_1x400.png +0 -0
  78. data/coverage/assets/0.13.1/images/ui-bg_highlight-soft_75_cccccc_1x100.png +0 -0
  79. data/coverage/assets/0.13.1/images/ui-icons_222222_256x240.png +0 -0
  80. data/coverage/assets/0.13.1/images/ui-icons_2e83ff_256x240.png +0 -0
  81. data/coverage/assets/0.13.1/images/ui-icons_454545_256x240.png +0 -0
  82. data/coverage/assets/0.13.1/images/ui-icons_888888_256x240.png +0 -0
  83. data/coverage/assets/0.13.1/images/ui-icons_cd0a0a_256x240.png +0 -0
  84. data/coverage/assets/0.13.1/loading.gif +0 -0
  85. data/coverage/assets/0.13.1/magnify.png +0 -0
  86. data/coverage/index.html +0 -15014
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'async'
5
+ require 'async/http'
6
+
7
+ describe LinkChecker::Async::HTTP::Checker do
8
+ module AsyncTestLinkChecker
9
+ class Task < LinkChecker::Async::HTTP::Task; end
10
+
11
+ class LinkChecker < LinkChecker::Async::HTTP::Checker
12
+ def check(url, options = {})
13
+ super
14
+ run
15
+ end
16
+ end
17
+ end
18
+
19
+ before :all do
20
+ VCR.configure do |config|
21
+ config.hook_into :webmock
22
+ # async-http's WebMock adapter replays responses through a real HTTP/1
23
+ # parser, which rejects a Transfer-Encoding header alongside a
24
+ # Content-Length or an empty body. Let the adapter set Content-Length.
25
+ config.before_playback do |interaction|
26
+ interaction.response.headers.delete('Transfer-Encoding')
27
+ interaction.response.headers.delete_if { |key, _value| key.downcase == 'content-length' }
28
+ end
29
+ end
30
+ end
31
+
32
+ after do
33
+ LinkChecker::Async::HTTP::Config.reset
34
+ end
35
+
36
+ describe 'concurrency' do
37
+ let(:checker) { described_class.new(max_concurrency: 2) }
38
+ let(:counts) { [] }
39
+ let(:completed) { [] }
40
+
41
+ before do
42
+ active = 0
43
+ 5.times do
44
+ checker._queue do
45
+ counts << (active += 1)
46
+ sleep(0.005)
47
+ active -= 1
48
+ completed << active
49
+ end
50
+ end
51
+ end
52
+
53
+ it 'bounds active work and waits for queued tasks' do
54
+ checker.run
55
+ expect(counts.max).to eq(2)
56
+ expect(counts.size).to eq(5)
57
+ expect(completed.size).to eq(5)
58
+ expect(completed.last).to eq(0)
59
+ end
60
+
61
+ it 'rejects invalid limits' do
62
+ expect { described_class.new(max_concurrency: 0) }.to raise_error ArgumentError
63
+ end
64
+
65
+ it 'reuses clients for the same endpoint' do
66
+ expect(checker._client_for(URI('https://www.example.org/one'))).to equal(
67
+ checker._client_for(URI('https://www.example.org/two'))
68
+ )
69
+ checker.run
70
+ end
71
+ end
72
+
73
+ describe AsyncTestLinkChecker::LinkChecker do
74
+ it_behaves_like 'a link checker'
75
+
76
+ describe '.config' do
77
+ it 'returns the Config module' do
78
+ expect(LinkChecker::Async::HTTP.config).to eq LinkChecker::Async::HTTP::Config
79
+ end
80
+ end
81
+
82
+ context 'with timeout options', vcr: { cassette_name: '200' } do
83
+ before do
84
+ LinkChecker::Async::HTTP.configure do |config|
85
+ config.read_timeout = 5
86
+ config.open_timeout = 10
87
+ end
88
+ expect(Async::HTTP::Endpoint).to receive(:parse).with(
89
+ URI(url).to_s,
90
+ hash_including(timeout: 10)
91
+ ).and_call_original
92
+ end
93
+
94
+ include_context 'with url'
95
+
96
+ it 'creates requests with a default timeout' do
97
+ expect(result.success?).to be true
98
+ end
99
+ end
100
+
101
+ context 'with a protocol option', vcr: { cassette_name: '200' } do
102
+ before do
103
+ LinkChecker::Async::HTTP.configure do |config|
104
+ config.protocol = protocol
105
+ end
106
+ expect(Async::HTTP::Endpoint).to receive(:parse).with(
107
+ URI(url).to_s,
108
+ hash_including(protocol: expected_protocol)
109
+ ).and_call_original
110
+ end
111
+
112
+ include_context 'with url'
113
+
114
+ context 'http1' do
115
+ let(:protocol) { :http1 }
116
+ let(:expected_protocol) { Async::HTTP::Protocol::HTTP1 }
117
+
118
+ it 'creates requests using HTTP/1' do
119
+ expect(result.success?).to be true
120
+ end
121
+ end
122
+
123
+ context 'http2' do
124
+ let(:protocol) { :http2 }
125
+ let(:expected_protocol) { Async::HTTP::Protocol::HTTP2 }
126
+
127
+ it 'creates requests using HTTP/2' do
128
+ expect(result.success?).to be true
129
+ end
130
+ end
131
+ end
132
+
133
+ context 'with an unsupported protocol option' do
134
+ before do
135
+ LinkChecker::Async::HTTP.configure do |config|
136
+ config.protocol = :http3
137
+ end
138
+ end
139
+
140
+ include_context 'with url'
141
+
142
+ it 'raises an ArgumentError' do
143
+ expect(result.error?).to be true
144
+ expect(result.error.message).to eq('Unsupported protocol: :http3, expected :http1 or :http2')
145
+ end
146
+ end
147
+
148
+ context 'timeout' do
149
+ before do
150
+ stub_request(:get, 'https://www.example.org/').to_timeout
151
+ end
152
+
153
+ include_context 'with url'
154
+
155
+ around do |example|
156
+ VCR.turned_off { example.run }
157
+ end
158
+
159
+ it 'times out' do
160
+ expect(result.success?).to be false
161
+ expect(result.error?).to be true
162
+ end
163
+
164
+ context 'with metadata' do
165
+ let(:options) { { foo: :bar } }
166
+
167
+ it 'times out' do
168
+ expect(result.error?).to be true
169
+ expect(result.options).to eq(foo: :bar)
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ describe LinkChecker::Async::HTTP::Result do
176
+ describe '#code' do
177
+ it 'returns -1 when there is no response' do
178
+ request = instance_double(Protocol::HTTP::Request)
179
+ result = described_class.new(URI('https://www.example.org'), 'GET', nil, request, nil, {})
180
+ expect(result.code).to eq(-1)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -6,6 +6,10 @@ describe LinkChecker::Callbacks do
6
6
  subject do
7
7
  Class.new do
8
8
  include LinkChecker::Callbacks
9
+
10
+ def logger
11
+ @logger ||= Logger.new(nil)
12
+ end
9
13
  end.new
10
14
  end
11
15
 
@@ -40,4 +44,33 @@ describe LinkChecker::Callbacks do
40
44
  expect(subject).to have_received(:called!).with(:bar)
41
45
  end
42
46
  end
47
+
48
+ context 'method_missing' do
49
+ it 'invokes callback for a bang method' do
50
+ allow(subject).to receive(:called!)
51
+ subject.on :foo do |data|
52
+ subject.called! data
53
+ end
54
+ subject.foo! :foo
55
+ expect(subject).to have_received(:called!).with(:foo)
56
+ end
57
+
58
+ it 'raises NoMethodError for a non-bang method' do
59
+ expect { subject.foo }.to raise_error NoMethodError
60
+ end
61
+ end
62
+
63
+ context 'when a callback raises an error' do
64
+ before do
65
+ subject.on :foo do
66
+ raise StandardError, 'oops'
67
+ end
68
+ end
69
+
70
+ it 'rescues and logs the error' do
71
+ allow(subject.logger).to receive(:error)
72
+ expect(subject.send(:callback, :foo)).to be false
73
+ expect(subject.logger).to have_received(:error)
74
+ end
75
+ end
43
76
  end
@@ -2,9 +2,96 @@
2
2
  require 'spec_helper'
3
3
 
4
4
  describe LinkChecker::Checker do
5
- context 'config' do
5
+ describe LinkChecker::Cache do
6
+ let(:directory) { Dir.mktmpdir('link-checker-spec-') }
7
+ let(:file) { File.join(directory, 'cache.json') }
8
+ let(:cache) { described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default) }
9
+ let(:result) do
10
+ LinkChecker::Net::HTTP::Result.new(
11
+ URI('https://www.example.org/final'), 'GET', URI('https://www.example.org/'),
12
+ {}, Net::HTTPOK.new('1.1', '200', 'OK'), { location: 'private.html' }
13
+ )
14
+ end
15
+
16
+ after do
17
+ FileUtils.remove_entry(directory)
18
+ end
19
+
20
+ it 'expires successful entries using the configured TTL' do
21
+ cache.store('key', result)
22
+ allow(Time).to receive(:now).and_return(Time.now + 61)
23
+ expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
24
+ end
25
+
26
+ it 'retains the final URL and status without persisting metadata or headers' do
27
+ cache.store('key', result)
28
+ restored = cache.fetch('key', 'https://www.example.org/')
29
+ expect(restored.result_uri.to_s).to eq('https://www.example.org/final')
30
+ expect(restored.code).to eq(200)
31
+ data = JSON.parse(File.read(file)).fetch('entries').fetch('key')
32
+ expect(data.keys).to contain_exactly('checked_at', 'method', 'code', 'result_uri')
33
+ end
34
+
35
+ it 'merges writes from separate cache instances' do
36
+ other_cache = described_class.new({ file: file, ttl: 60 }, logger: LinkChecker::Logger.default)
37
+ cache.store('one', result)
38
+ other_cache.store('two', result)
39
+ expect(cache.fetch('one', 'https://www.example.org/')).not_to be_nil
40
+ expect(cache.fetch('two', 'https://www.example.org/')).not_to be_nil
41
+ end
42
+
43
+ it 'recovers from corrupted cache files' do
44
+ File.write(file, 'invalid JSON')
45
+ expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
46
+ cache.store('key', result)
47
+ expect(cache.fetch('key', 'https://www.example.org/').code).to eq(200)
48
+ end
49
+
50
+ it 'ignores malformed cache entries' do
51
+ File.write(file, JSON.generate('version' => 1, 'entries' => { 'key' => true }))
52
+ expect(cache.fetch('key', 'https://www.example.org/')).to be_nil
53
+ end
54
+
55
+ it 'does not store credentials in redirect URLs' do
56
+ result.result_uri = URI('https://user:password@www.example.org/')
57
+ cache.store('key', result)
58
+ expect(File).not_to exist(file)
59
+ end
60
+
61
+ it 'requires an explicit file and TTL' do
62
+ expect { described_class.new({}, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
63
+ expect { described_class.new({ file: file }, logger: LinkChecker::Logger.default) }.to raise_error ArgumentError
64
+ end
65
+
66
+ it 'does not turn a cache write failure into a failed link' do
67
+ allow(FileUtils).to receive(:mkdir_p).and_raise(Errno::EACCES)
68
+ expect { cache.store('key', result) }.not_to raise_error
69
+ end
70
+ end
71
+
72
+ context 'config' do
6
73
  it 'requires at least one method' do
7
- expect { LinkChecker::Checker.new(methods: []) }.to raise_error ArgumentError, 'Missing methods.'
74
+ expect { described_class.new(methods: []) }.to raise_error ArgumentError, 'Missing methods.'
75
+ end
76
+
77
+ it 'rejects an invalid redirect limit' do
78
+ expect { described_class.new(max_redirects: -1) }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
79
+ end
80
+
81
+ it 'validates retry delays and backoff' do
82
+ expect { described_class.new(retry_after: -1) }.to raise_error ArgumentError
83
+ expect { described_class.new(retry_backoff: 0.5) }.to raise_error ArgumentError
84
+ expect { described_class.new(retry_jitter: 2) }.to raise_error ArgumentError
85
+ expect { described_class.new(max_retry_delay: Float::INFINITY) }.to raise_error ArgumentError
86
+ end
87
+
88
+ it 'rejects invalid retry status codes' do
89
+ expect { described_class.new(retry_status_codes: ['503']) }.to raise_error ArgumentError
90
+ end
91
+
92
+ it 'allows per-instance disabling of global Retry-After handling' do
93
+ LinkChecker.config.respect_retry_after = true
94
+ expect(described_class.new(respect_retry_after: false).respect_retry_after).to be false
8
95
  end
9
96
  end
10
97
  end
@@ -19,6 +19,12 @@ describe LinkChecker::Config do
19
19
  it 'requires a positive integer' do
20
20
  expect { LinkChecker.config.retries = -1 }.to raise_error ArgumentError, 'Invalid number of retries: -1'
21
21
  end
22
+
23
+ context 'max_redirects' do
24
+ it 'rejects negative limits' do
25
+ expect { LinkChecker.config.max_redirects = -1 }.to raise_error ArgumentError, 'Invalid redirect limit: -1'
26
+ end
27
+ end
22
28
  end
23
29
  end
24
30
 
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe LinkChecker::Errors::RedirectLoopError do
6
+ subject(:error) do
7
+ described_class.new(['https://www.example.org', 'https://www.example.org/redirect'])
8
+ end
9
+
10
+ it 'returns the last url' do
11
+ expect(error.url).to eq 'https://www.example.org/redirect'
12
+ end
13
+
14
+ it 'formats a message with all urls' do
15
+ expect(error.message)
16
+ .to eq 'Redirect loop: https://www.example.org -> https://www.example.org/redirect.'
17
+ end
18
+ end
@@ -15,6 +15,27 @@ describe LinkChecker::Net::HTTP::Checker do
15
15
 
16
16
  it_behaves_like 'a link checker'
17
17
 
18
+ context 'with an HTTP URL' do
19
+ let(:checker) { described_class.new(methods: ['GET']) }
20
+
21
+ around do |example|
22
+ VCR.turned_off { example.run }
23
+ end
24
+
25
+ it 'does not enable TLS' do
26
+ stub_request(:get, 'http://www.example.org/').to_return(status: 200)
27
+ checker.check('http://www.example.org/')
28
+ expect(checker.results[:success].first.code).to eq(200)
29
+ expect(a_request(:get, 'http://www.example.org/')).to have_been_made.once
30
+ end
31
+ end
32
+
33
+ describe '.config' do
34
+ it 'returns the Config module' do
35
+ expect(LinkChecker::Net::HTTP.config).to eq LinkChecker::Net::HTTP::Config
36
+ end
37
+ end
38
+
18
39
  context 'with timeout options', vcr: { cassette_name: '200' } do
19
40
  before do
20
41
  LinkChecker::Net::HTTP.configure do |config|
@@ -58,4 +79,15 @@ describe LinkChecker::Net::HTTP::Checker do
58
79
  end
59
80
  end
60
81
  end
82
+
83
+ describe LinkChecker::Net::HTTP::Result do
84
+ describe '#code' do
85
+ it 'returns -1 when the response raises' do
86
+ response = instance_double(Net::HTTPResponse)
87
+ allow(response).to receive(:code).and_raise(StandardError)
88
+ result = described_class.new(URI('https://www.example.org'), 'GET', nil, nil, response, {})
89
+ expect(result.code).to eq(-1)
90
+ end
91
+ end
92
+ end
61
93
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe LinkChecker::ResultError do
6
+ subject(:result) do
7
+ described_class.new('https://www.example.org', 'GET', 'https://www.example.org', StandardError.new('oops'))
8
+ end
9
+
10
+ it 'is an error' do
11
+ expect(result.error?).to be true
12
+ end
13
+
14
+ it 'returns the error class name as code' do
15
+ expect(result.code).to eq 'StandardError'
16
+ end
17
+
18
+ it 'classifies native timeouts without replacing the exception' do
19
+ error = Net::ReadTimeout.new('timeout')
20
+ result.error = error
21
+ expect(result.error_type).to eq(:timeout)
22
+ expect(result.error).to equal(error)
23
+ end
24
+
25
+ it 'classifies native connection failures' do
26
+ result.error = Errno::ECONNREFUSED.new
27
+ expect(result.error_type).to eq(:connection)
28
+ end
29
+
30
+ it 'does not classify programming errors as transient' do
31
+ result.error = ArgumentError.new
32
+ expect(result.error_type).to eq(:other)
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe LinkChecker::Result do
6
+ subject(:result) do
7
+ described_class.new('https://www.example.org', 'GET', 'https://www.example.org')
8
+ end
9
+
10
+ it 'defaults to not successful' do
11
+ expect(result.success?).to be false
12
+ end
13
+
14
+ it 'defaults to not a failure' do
15
+ expect(result.failure?).to be false
16
+ end
17
+
18
+ it 'defaults to not an error' do
19
+ expect(result.error?).to be false
20
+ end
21
+
22
+ it 'defaults to not a redirect' do
23
+ expect(result.redirect?).to be false
24
+ end
25
+
26
+ it 'defaults redirect_to to nil' do
27
+ expect(result.redirect_to).to be_nil
28
+ end
29
+
30
+ it 'defaults request_headers to an empty hash' do
31
+ expect(result.request_headers).to eq({})
32
+ end
33
+
34
+ it 'defaults code to nil' do
35
+ expect(result.code).to be_nil
36
+ end
37
+
38
+ it 'defaults error to nil' do
39
+ expect(result.error).to be_nil
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe LinkChecker::Task do
6
+ subject(:task) do
7
+ described_class.new(
8
+ LinkChecker::Net::HTTP::Checker.new,
9
+ 'https://www.example.org',
10
+ 'GET',
11
+ 'https://www.example.org'
12
+ )
13
+ end
14
+
15
+ describe '#run!' do
16
+ it 'raises NotImplementedError' do
17
+ expect { task.run! }.to raise_error NotImplementedError
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe LinkChecker::Tasks do
6
+ describe 'retry delays' do
7
+ let(:checker) { LinkChecker::Net::HTTP::Checker.new }
8
+ let(:tasks) { described_class.new(checker, LinkChecker::Net::HTTP::Task, 'https://www.example.org', ['GET']) }
9
+ let(:headers) { { 'Retry-After' => '60' } }
10
+ let(:result) { instance_double(LinkChecker::Net::HTTP::Result, response_headers: headers) }
11
+
12
+ before do
13
+ allow(tasks).to receive(:result).and_return(result)
14
+ end
15
+
16
+ it 'parses delta-seconds' do
17
+ expect(tasks.send(:_server_retry_delay)).to eq(60)
18
+ end
19
+
20
+ context 'with a past HTTP date' do
21
+ let(:headers) { { 'Retry-After' => (Time.now - 60).httpdate } }
22
+
23
+ it 'does not delay a retry' do
24
+ expect(tasks.send(:_server_retry_delay)).to eq(0)
25
+ end
26
+ end
27
+
28
+ context 'with an invalid negative delay' do
29
+ let(:headers) { { 'Retry-After' => '-5' } }
30
+
31
+ it 'does not delay a retry' do
32
+ expect(tasks.send(:_server_retry_delay)).to eq(0)
33
+ end
34
+ end
35
+
36
+ it 'caps configured delays' do
37
+ checker.retry_after = 120
38
+ checker.max_retry_delay = 10
39
+ expect(tasks.send(:_retry_delay)).to eq(10)
40
+ end
41
+
42
+ it 'adds jitter without reducing the minimum delay' do
43
+ checker.retry_after = 2
44
+ checker.retry_jitter = 0.5
45
+ allow(tasks).to receive(:rand).and_return(0.5)
46
+ expect(tasks.send(:_retry_delay)).to eq(2.5)
47
+ end
48
+ end
49
+
50
+ describe '#_handle_result' do
51
+ # Reproduces issue #12: SystemStackError when logging causes repeated errors
52
+ # https://github.com/dblock/ruby-link-checker/issues/12
53
+ context 'when logger.info raises an error repeatedly' do
54
+ let(:checker) { LinkChecker::Net::HTTP::Checker.new(methods: ['GET']) }
55
+ let(:task_klass) { LinkChecker::Net::HTTP::Task }
56
+ let(:url) { 'https://www.example.org' }
57
+
58
+ before do
59
+ stub_request(:get, url).to_return(status: 200, body: '', headers: {})
60
+ end
61
+
62
+ it 'does not cause infinite recursion leading to SystemStackError' do
63
+ tasks = described_class.new(checker, task_klass, url, ['GET'])
64
+
65
+ # Stub logger to always raise an error on info, simulating the scenario
66
+ # from issue #12 where logging causes a stack overflow due to the
67
+ # rescue block in _handle_result recursively calling itself
68
+ allow(tasks).to receive(:logger).and_return(
69
+ instance_double(LinkChecker::Logger).tap do |logger|
70
+ allow(logger).to receive(:info).and_raise(StandardError, 'stack level too deep')
71
+ allow(logger).to receive(:error)
72
+ end
73
+ )
74
+
75
+ # Should not raise SystemStackError - the error should be handled gracefully
76
+ expect { tasks.execute! }.not_to raise_error
77
+ end
78
+ end
79
+ end
80
+
81
+ describe '#retries' do
82
+ let(:checker) { LinkChecker::Net::HTTP::Checker.new(methods: ['GET'], retries: 2) }
83
+ let(:task_klass) { LinkChecker::Net::HTTP::Task }
84
+ let(:url) { 'https://www.example.org' }
85
+
86
+ it 'delegates to the checker' do
87
+ tasks = described_class.new(checker, task_klass, url, ['GET'])
88
+ expect(tasks.send(:retries)).to eq 2
89
+ end
90
+ end
91
+ end