pecorino 0.7.1 → 0.7.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 +4 -4
- data/.github/workflows/ci.yml +42 -19
- data/.gitignore +1 -0
- data/CHANGELOG.md +4 -0
- data/README.md +22 -2
- data/Rakefile +12 -1
- data/gemfiles/Gemfile_ruby27_rails7 +19 -0
- data/gemfiles/Gemfile_ruby30_rails8 +15 -0
- data/lib/pecorino/adapters/base_adapter.rb +1 -1
- data/lib/pecorino/version.rb +1 -1
- data/pecorino.gemspec +1 -14
- data/rbi/pecorino.rbi +905 -0
- data/test/adapters/adapter_test_methods.rb +259 -0
- data/test/adapters/memory_adapter_test.rb +12 -0
- data/test/adapters/postgres_adapter_test.rb +69 -0
- data/test/adapters/redis_adapter_test.rb +27 -0
- data/test/adapters/sqlite_adapter_test.rb +46 -0
- data/test/block_test.rb +23 -0
- data/test/cached_throttle_test.rb +100 -0
- data/test/leaky_bucket_test.rb +161 -0
- data/test/pecorino_test.rb +9 -0
- data/test/test_helper.rb +12 -0
- data/test/throttle_test.rb +119 -0
- metadata +19 -138
- data/Gemfile +0 -6
@@ -0,0 +1,259 @@
|
|
1
|
+
# The module contains the conformance tests for a storage adapter for Pecorino. A well-behaved adapter
|
2
|
+
# should pass all of these tests. When creating a new adapter include this module in your test case
|
3
|
+
# and overload the `create_adapter` method
|
4
|
+
module AdapterTestMethods
|
5
|
+
LEVEL_DELTA = 0.1
|
6
|
+
|
7
|
+
def adapter
|
8
|
+
@adapter ||= create_adapter
|
9
|
+
end
|
10
|
+
|
11
|
+
def create_adapter
|
12
|
+
raise "Adapter test subclass needs to return an adapter implementation from here."
|
13
|
+
end
|
14
|
+
|
15
|
+
def random_key
|
16
|
+
Random.new(Minitest.seed).hex(4)
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_state_returns_zero_for_nonexistent_bucket
|
20
|
+
k = random_key
|
21
|
+
leak_rate = 2
|
22
|
+
capacity = 3
|
23
|
+
|
24
|
+
level, is_full = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
25
|
+
assert_equal 0, level
|
26
|
+
assert_equal is_full, false
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_bucket_lifecycle_with_unbounded_fillups
|
30
|
+
k = random_key
|
31
|
+
leak_rate = 2
|
32
|
+
capacity = 1
|
33
|
+
|
34
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.3)
|
35
|
+
assert_in_delta level, 0.3, LEVEL_DELTA
|
36
|
+
assert_equal false, is_full
|
37
|
+
|
38
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.35)
|
39
|
+
assert_in_delta level, 0.65, LEVEL_DELTA
|
40
|
+
assert_equal false, is_full
|
41
|
+
|
42
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.7)
|
43
|
+
assert_in_delta level, 1.0, LEVEL_DELTA
|
44
|
+
assert_equal true, is_full
|
45
|
+
|
46
|
+
level, _ = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
47
|
+
assert_in_delta level, 1.0, LEVEL_DELTA
|
48
|
+
|
49
|
+
sleep(0.25)
|
50
|
+
level, _ = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
51
|
+
assert_in_delta level, 0.5, LEVEL_DELTA
|
52
|
+
|
53
|
+
sleep(0.25)
|
54
|
+
level, _ = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
55
|
+
assert_in_delta level, 0.0, LEVEL_DELTA
|
56
|
+
|
57
|
+
sleep(0.25)
|
58
|
+
level, _ = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
59
|
+
assert_in_delta level, 0.0, LEVEL_DELTA
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_clamps_fillup_with_negative_value
|
63
|
+
k = random_key
|
64
|
+
leak_rate = 1.1
|
65
|
+
capacity = 15
|
66
|
+
|
67
|
+
level, _, _ = adapter.state(key: k, leak_rate: leak_rate, capacity: capacity)
|
68
|
+
assert_in_delta level, 0, 0.0001
|
69
|
+
|
70
|
+
level, _, _ = adapter.add_tokens(key: k, leak_rate: leak_rate, capacity: capacity, n_tokens: -10)
|
71
|
+
assert_in_delta level, 0, 0.1
|
72
|
+
end
|
73
|
+
|
74
|
+
def test_bucket_lifecycle_with_negative_fillups
|
75
|
+
k = random_key
|
76
|
+
leak_rate = 2
|
77
|
+
capacity = 1
|
78
|
+
|
79
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 1)
|
80
|
+
assert_in_delta level, 1.0, LEVEL_DELTA
|
81
|
+
assert_equal true, is_full
|
82
|
+
|
83
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: -0.35)
|
84
|
+
assert_in_delta level, 0.65, LEVEL_DELTA
|
85
|
+
assert_equal false, is_full
|
86
|
+
|
87
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: -0.4)
|
88
|
+
assert_in_delta level, 0.25, LEVEL_DELTA
|
89
|
+
assert_equal false, is_full
|
90
|
+
|
91
|
+
level, is_full = adapter.add_tokens(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: -0.4)
|
92
|
+
assert_in_delta level, 0.0, LEVEL_DELTA
|
93
|
+
assert_equal false, is_full
|
94
|
+
end
|
95
|
+
|
96
|
+
def test_bucket_add_tokens_conditionally_accepts_single_fillup_to_capacity
|
97
|
+
k = random_key
|
98
|
+
leak_rate = 2
|
99
|
+
capacity = 1
|
100
|
+
|
101
|
+
level, is_full, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 1)
|
102
|
+
assert_in_delta level, 1.0, LEVEL_DELTA
|
103
|
+
assert_equal is_full, true
|
104
|
+
assert_equal did_accept, true
|
105
|
+
end
|
106
|
+
|
107
|
+
def test_bucket_add_tokens_conditionally_accepts_multiple_fillups_to_capacity
|
108
|
+
k = random_key
|
109
|
+
leak_rate = 2
|
110
|
+
capacity = 1
|
111
|
+
|
112
|
+
level, _, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.5)
|
113
|
+
assert_in_delta level, 0.5, LEVEL_DELTA
|
114
|
+
assert_equal did_accept, true
|
115
|
+
|
116
|
+
level, _, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.5)
|
117
|
+
assert_in_delta level, 1.0, LEVEL_DELTA
|
118
|
+
assert_equal did_accept, true
|
119
|
+
end
|
120
|
+
|
121
|
+
def test_bucket_lifecycle_rejects_single_fillup_above_capacity
|
122
|
+
k = random_key
|
123
|
+
leak_rate = 2
|
124
|
+
capacity = 1
|
125
|
+
|
126
|
+
level, is_full, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 1.2)
|
127
|
+
assert_in_delta level, 0.0, LEVEL_DELTA
|
128
|
+
assert_equal is_full, false
|
129
|
+
assert_equal did_accept, false
|
130
|
+
end
|
131
|
+
|
132
|
+
def test_bucket_lifecycle_rejects_conditional_fillup_that_would_overflow
|
133
|
+
k = random_key
|
134
|
+
leak_rate = 2
|
135
|
+
capacity = 1
|
136
|
+
|
137
|
+
3.times do
|
138
|
+
_level, is_full, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.3)
|
139
|
+
assert_equal is_full, false
|
140
|
+
assert_equal did_accept, true
|
141
|
+
end
|
142
|
+
|
143
|
+
level, is_full, did_accept = adapter.add_tokens_conditionally(key: k, capacity: capacity, leak_rate: leak_rate, n_tokens: 0.3)
|
144
|
+
assert_in_delta level, 0.9, LEVEL_DELTA
|
145
|
+
assert_equal is_full, false
|
146
|
+
assert_equal did_accept, false
|
147
|
+
end
|
148
|
+
|
149
|
+
def test_bucket_lifecycle_handles_conditional_fillup_in_steps
|
150
|
+
key = random_key
|
151
|
+
leak_rate = 1.0
|
152
|
+
capacity = 1.0
|
153
|
+
|
154
|
+
counter = 0
|
155
|
+
try_fillup = ->(fillup_by, should_have_reached_level, should_have_accepted) {
|
156
|
+
counter += 1
|
157
|
+
level, _, did_accept = adapter.add_tokens_conditionally(key: key, capacity: capacity, leak_rate: leak_rate, n_tokens: fillup_by)
|
158
|
+
assert_equal did_accept, should_have_accepted, "Update #{counter} did_accept should be #{should_have_accepted}"
|
159
|
+
assert_in_delta should_have_reached_level, level, 0.1
|
160
|
+
}
|
161
|
+
|
162
|
+
try_fillup.call(1.1, 0.0, false) # Oversized fillup must be refused outright
|
163
|
+
try_fillup.call(0.3, 0.3, true)
|
164
|
+
try_fillup.call(0.3, 0.6, true)
|
165
|
+
try_fillup.call(0.3, 0.9, true)
|
166
|
+
try_fillup.call(0.3, 0.9, false) # Would take the bucket to 1.2, so must be rejected
|
167
|
+
|
168
|
+
sleep(0.2) # Leak out 0.2 tokens
|
169
|
+
|
170
|
+
try_fillup.call(0.3, 1.0, true)
|
171
|
+
try_fillup.call(-2, 0.0, true) # A negative fillup is permitted since it will never take the bucket above capacity
|
172
|
+
try_fillup.call(1.0, 1.0, true) # Filling up in one step should be permitted
|
173
|
+
end
|
174
|
+
|
175
|
+
def test_bucket_lifecycle_allows_conditional_fillup_after_leaking_out
|
176
|
+
rng = Random.new(Minitest.seed)
|
177
|
+
12.times do |i|
|
178
|
+
key = rng.hex(4)
|
179
|
+
capacity = 30
|
180
|
+
leak_rate = capacity / 0.5
|
181
|
+
|
182
|
+
_, _, did_accept = adapter.add_tokens_conditionally(key: key, capacity: capacity, leak_rate: leak_rate, n_tokens: 29.6)
|
183
|
+
assert did_accept, "Should have accepted the topup on iteration #{i}"
|
184
|
+
|
185
|
+
_, _, did_accept = adapter.add_tokens_conditionally(key: key, capacity: capacity, leak_rate: leak_rate, n_tokens: 1)
|
186
|
+
refute did_accept, "Should have refused the topup on iteration #{i}"
|
187
|
+
|
188
|
+
sleep 0.6 # Spend enough time to allow the bucket to leak out completely
|
189
|
+
_, _, did_accept = adapter.add_tokens_conditionally(key: key, capacity: capacity, leak_rate: leak_rate, n_tokens: 1)
|
190
|
+
assert did_accept, "Once the bucket has leaked out to 0 the fillup should be accepted again on iteration #{i}"
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def test_set_block_sets_a_block
|
195
|
+
key = random_key
|
196
|
+
now = Time.now.utc
|
197
|
+
block_duration_s = 2.2
|
198
|
+
|
199
|
+
assert_nil adapter.blocked_until(key: key)
|
200
|
+
|
201
|
+
set_block_result = adapter.set_block(key: key, block_for: block_duration_s)
|
202
|
+
assert_kind_of Time, set_block_result
|
203
|
+
assert_in_delta now + block_duration_s, set_block_result, 0.1
|
204
|
+
|
205
|
+
blocked_until = adapter.blocked_until(key: key)
|
206
|
+
assert_in_delta blocked_until, set_block_result, 0.1
|
207
|
+
end
|
208
|
+
|
209
|
+
def test_set_block_does_not_set_block_in_the_past
|
210
|
+
key = random_key
|
211
|
+
assert_nil adapter.blocked_until(key: key)
|
212
|
+
assert_raise(ArgumentError) { adapter.set_block(key: key, block_for: -20) }
|
213
|
+
assert_nil adapter.blocked_until(key: key)
|
214
|
+
end
|
215
|
+
|
216
|
+
def test_set_block_does_not_set_block_which_would_expire_immediately
|
217
|
+
key = random_key
|
218
|
+
assert_nil adapter.blocked_until(key: key)
|
219
|
+
assert_raise(ArgumentError) { adapter.set_block(key: key, block_for: 0) }
|
220
|
+
assert_nil adapter.blocked_until(key: key)
|
221
|
+
end
|
222
|
+
|
223
|
+
def test_prune
|
224
|
+
key = random_key
|
225
|
+
capacity = 30
|
226
|
+
leak_rate = capacity / 0.5
|
227
|
+
|
228
|
+
adapter.add_tokens_conditionally(key: key, capacity: capacity, leak_rate: leak_rate, n_tokens: 29.6)
|
229
|
+
adapter.set_block(key: key, block_for: 0.5)
|
230
|
+
|
231
|
+
sleep 2
|
232
|
+
|
233
|
+
# Both the leaky bucket and the block should have expired by now, and `prune` should not raise
|
234
|
+
assert_nothing_raised { adapter.prune }
|
235
|
+
assert_nil adapter.blocked_until(key: key)
|
236
|
+
end
|
237
|
+
|
238
|
+
def test_create_tables
|
239
|
+
raise "This has to either be a no-op in your test (if your adapter doesn't need any tables) or needs to be written"
|
240
|
+
end
|
241
|
+
|
242
|
+
def xtest_should_accept_threadsafe_conditional_fillups
|
243
|
+
k = random_key
|
244
|
+
capacity = 30
|
245
|
+
leak_rate = capacity / 0.5
|
246
|
+
|
247
|
+
threads = 3.times.map do
|
248
|
+
Thread.new do
|
249
|
+
9.times do
|
250
|
+
adapter.add_tokens_conditionally(key: k, leak_rate: leak_rate, capacity: capacity, n_tokens: 1)
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
threads.map(&:join)
|
255
|
+
|
256
|
+
level, _ = adapter.state(key: k, capacity: capacity, leak_rate: leak_rate)
|
257
|
+
assert_in_delta level, (3 * 9), LEVEL_DELTA
|
258
|
+
end
|
259
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require_relative "../test_helper"
|
2
|
+
require_relative "adapter_test_methods"
|
3
|
+
|
4
|
+
class MemoryAdapterTest < ActiveSupport::TestCase
|
5
|
+
include AdapterTestMethods
|
6
|
+
|
7
|
+
def create_adapter
|
8
|
+
Pecorino::Adapters::MemoryAdapter.new
|
9
|
+
end
|
10
|
+
|
11
|
+
undef :test_create_tables
|
12
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative "../test_helper"
|
2
|
+
require_relative "adapter_test_methods"
|
3
|
+
|
4
|
+
class PostgresAdapterTest < ActiveSupport::TestCase
|
5
|
+
include AdapterTestMethods
|
6
|
+
|
7
|
+
setup { create_postgres_database_if_none }
|
8
|
+
teardown { truncate_test_tables }
|
9
|
+
|
10
|
+
def self.establish_connection(**options)
|
11
|
+
ActiveRecord::Base.establish_connection(
|
12
|
+
adapter: "postgresql",
|
13
|
+
connect_timeout: 2,
|
14
|
+
**options
|
15
|
+
)
|
16
|
+
end
|
17
|
+
|
18
|
+
def create_adapter
|
19
|
+
Pecorino::Adapters::PostgresAdapter.new(ActiveRecord::Base)
|
20
|
+
end
|
21
|
+
|
22
|
+
SEED_DB_NAME = -> { "pecorino_tests_%s" % Random.new(Minitest.seed).hex(4) }
|
23
|
+
|
24
|
+
def create_postgres_database_if_none
|
25
|
+
self.class.establish_connection(encoding: "unicode", database: SEED_DB_NAME.call)
|
26
|
+
ActiveRecord::Base.connection.execute("SELECT 1 FROM pecorino_leaky_buckets")
|
27
|
+
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
|
28
|
+
create_postgres_database
|
29
|
+
retry
|
30
|
+
rescue ActiveRecord::StatementInvalid
|
31
|
+
retained_adapter = adapter # the schema define block is run via instance_exec so it does not retain scope
|
32
|
+
ActiveRecord::Schema.define(version: 1) do |via_definer|
|
33
|
+
retained_adapter.create_tables(via_definer)
|
34
|
+
end
|
35
|
+
retry
|
36
|
+
end
|
37
|
+
|
38
|
+
def create_postgres_database
|
39
|
+
ActiveRecord::Migration.verbose = false
|
40
|
+
self.class.establish_connection(database: "postgres")
|
41
|
+
ActiveRecord::Base.connection.create_database(SEED_DB_NAME.call, charset: :unicode)
|
42
|
+
ActiveRecord::Base.connection.close
|
43
|
+
self.class.establish_connection(encoding: "unicode", database: SEED_DB_NAME.call)
|
44
|
+
end
|
45
|
+
|
46
|
+
def truncate_test_tables
|
47
|
+
ActiveRecord::Base.connection.execute("TRUNCATE TABLE pecorino_leaky_buckets")
|
48
|
+
ActiveRecord::Base.connection.execute("TRUNCATE TABLE pecorino_blocks")
|
49
|
+
end
|
50
|
+
|
51
|
+
def test_create_tables
|
52
|
+
ActiveRecord::Base.transaction do
|
53
|
+
ActiveRecord::Base.connection.execute("DROP TABLE pecorino_leaky_buckets")
|
54
|
+
ActiveRecord::Base.connection.execute("DROP TABLE pecorino_blocks")
|
55
|
+
# The adapter has to be in a variable as the schema definition is scoped to the migrator, not self
|
56
|
+
retained_adapter = create_adapter # the schema define block is run via instance_exec so it does not retain scope
|
57
|
+
ActiveRecord::Schema.define(version: 1) do |via_definer|
|
58
|
+
retained_adapter.create_tables(via_definer)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
assert true
|
62
|
+
end
|
63
|
+
|
64
|
+
Minitest.after_run do
|
65
|
+
ActiveRecord::Base.connection.close
|
66
|
+
establish_connection(database: "postgres")
|
67
|
+
ActiveRecord::Base.connection.drop_database(SEED_DB_NAME.call)
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_relative "../test_helper"
|
2
|
+
require_relative "adapter_test_methods"
|
3
|
+
|
4
|
+
class RedisAdapterTest < ActiveSupport::TestCase
|
5
|
+
include AdapterTestMethods
|
6
|
+
|
7
|
+
teardown { delete_created_keys }
|
8
|
+
|
9
|
+
def create_adapter
|
10
|
+
Pecorino::Adapters::RedisAdapter.new(new_redis, key_prefix: key_prefix)
|
11
|
+
end
|
12
|
+
|
13
|
+
def key_prefix
|
14
|
+
"pecorino-test" + Random.new(Minitest.seed).bytes(4)
|
15
|
+
end
|
16
|
+
|
17
|
+
def delete_created_keys
|
18
|
+
r = new_redis
|
19
|
+
r.del(r.keys(key_prefix + "*"))
|
20
|
+
end
|
21
|
+
|
22
|
+
def new_redis
|
23
|
+
Redis.new
|
24
|
+
end
|
25
|
+
|
26
|
+
undef :test_create_tables
|
27
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require_relative "../test_helper"
|
2
|
+
require_relative "adapter_test_methods"
|
3
|
+
|
4
|
+
class SqliteAdapterTest < ActiveSupport::TestCase
|
5
|
+
include AdapterTestMethods
|
6
|
+
|
7
|
+
setup { create_sqlite_db }
|
8
|
+
teardown { drop_sqlite_db }
|
9
|
+
|
10
|
+
def create_sqlite_db
|
11
|
+
ActiveRecord::Migration.verbose = false
|
12
|
+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: db_filename)
|
13
|
+
|
14
|
+
ActiveRecord::Schema.define(version: 1) do |via_definer|
|
15
|
+
Pecorino.create_tables(via_definer)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def drop_sqlite_db
|
20
|
+
ActiveRecord::Base.connection.close
|
21
|
+
FileUtils.rm_rf(db_filename)
|
22
|
+
FileUtils.rm_rf(db_filename + "-wal")
|
23
|
+
FileUtils.rm_rf(db_filename + "-shm")
|
24
|
+
end
|
25
|
+
|
26
|
+
def db_filename
|
27
|
+
"pecorino_tests_%s.sqlite3" % Random.new(Minitest.seed).hex(4)
|
28
|
+
end
|
29
|
+
|
30
|
+
def create_adapter
|
31
|
+
Pecorino::Adapters::SqliteAdapter.new(ActiveRecord::Base)
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_create_tables
|
35
|
+
ActiveRecord::Base.transaction do
|
36
|
+
ActiveRecord::Base.connection.execute("DROP TABLE pecorino_leaky_buckets")
|
37
|
+
ActiveRecord::Base.connection.execute("DROP TABLE pecorino_blocks")
|
38
|
+
# The adapter has to be in a variable as the schema definition is scoped to the migrator, not self
|
39
|
+
retained_adapter = create_adapter # the schema define block is run via instance_exec so it does not retain scope
|
40
|
+
ActiveRecord::Schema.define(version: 1) do |via_definer|
|
41
|
+
retained_adapter.create_tables(via_definer)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
assert true
|
45
|
+
end
|
46
|
+
end
|
data/test/block_test.rb
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
require "base64"
|
5
|
+
|
6
|
+
class BlockTest < ActiveSupport::TestCase
|
7
|
+
test "sets a block" do
|
8
|
+
k = Base64.strict_encode64(Random.bytes(4))
|
9
|
+
assert_nil Pecorino::Block.blocked_until(key: k)
|
10
|
+
assert Pecorino::Block.set!(key: k, block_for: 30.minutes)
|
11
|
+
|
12
|
+
blocked_until = Pecorino::Block.blocked_until(key: k)
|
13
|
+
assert_in_delta Time.now + 30.minutes, blocked_until, 10
|
14
|
+
end
|
15
|
+
|
16
|
+
test "does not return a block which has lapsed" do
|
17
|
+
k = Base64.strict_encode64(Random.bytes(4))
|
18
|
+
assert_nil Pecorino::Block.blocked_until(key: k)
|
19
|
+
Pecorino::Block.set!(key: k, block_for: -30.minutes)
|
20
|
+
blocked_until = Pecorino::Block.blocked_until(key: k)
|
21
|
+
assert_nil blocked_until
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "test_helper"
|
4
|
+
|
5
|
+
class CachedThrottleTest < ActiveSupport::TestCase
|
6
|
+
def adapter
|
7
|
+
@adapter ||= Pecorino::Adapters::MemoryAdapter.new
|
8
|
+
end
|
9
|
+
|
10
|
+
test "caches results of request! and correctly raises Throttled until the block is lifted" do
|
11
|
+
store = ActiveSupport::Cache::MemoryStore.new
|
12
|
+
throttle = Pecorino::Throttle.new(key: Random.uuid, capacity: 2, over_time: 1, block_for: 10, adapter: adapter)
|
13
|
+
cached_throttle = Pecorino::CachedThrottle.new(store, throttle)
|
14
|
+
|
15
|
+
state1 = cached_throttle.request!
|
16
|
+
state2 = cached_throttle.request!
|
17
|
+
ex = assert_raises(Pecorino::Throttle::Throttled) do
|
18
|
+
cached_throttle.request!
|
19
|
+
end
|
20
|
+
|
21
|
+
assert_kind_of Pecorino::Throttle::State, state1
|
22
|
+
assert_kind_of Pecorino::Throttle::State, state2
|
23
|
+
|
24
|
+
assert_equal throttle, ex.throttle
|
25
|
+
|
26
|
+
# Delete the method on the actual throttle as it should not be called anymore until the block is lifted
|
27
|
+
class << throttle
|
28
|
+
undef :request!
|
29
|
+
end
|
30
|
+
assert_raises(Pecorino::Throttle::Throttled) do
|
31
|
+
cached_throttle.request!
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
test "caches results of able_to_accept? until the block is lifted" do
|
36
|
+
store = ActiveSupport::Cache::MemoryStore.new
|
37
|
+
throttle = Pecorino::Throttle.new(key: Random.uuid, capacity: 2, over_time: 1, block_for: 10, adapter: adapter)
|
38
|
+
cached_throttle = Pecorino::CachedThrottle.new(store, throttle)
|
39
|
+
|
40
|
+
cached_throttle.request(1)
|
41
|
+
cached_throttle.request(1)
|
42
|
+
cached_throttle.request(1)
|
43
|
+
|
44
|
+
refute cached_throttle.able_to_accept?(1)
|
45
|
+
|
46
|
+
# Delete the method on the actual throttle as it should not be called anymore until the block is lifted
|
47
|
+
class << throttle
|
48
|
+
undef :able_to_accept?
|
49
|
+
end
|
50
|
+
|
51
|
+
refute cached_throttle.able_to_accept?(1)
|
52
|
+
end
|
53
|
+
|
54
|
+
test "caches results of request() and correctly returns cached state until the block is lifted" do
|
55
|
+
store = ActiveSupport::Cache::MemoryStore.new
|
56
|
+
throttle = Pecorino::Throttle.new(key: Random.uuid, capacity: 2, over_time: 1, block_for: 10, adapter: adapter)
|
57
|
+
cached_throttle = Pecorino::CachedThrottle.new(store, throttle)
|
58
|
+
|
59
|
+
state1 = cached_throttle.request(1)
|
60
|
+
state2 = cached_throttle.request(1)
|
61
|
+
state3 = cached_throttle.request(3)
|
62
|
+
|
63
|
+
assert_kind_of Pecorino::Throttle::State, state1
|
64
|
+
assert_kind_of Pecorino::Throttle::State, state2
|
65
|
+
assert_kind_of Pecorino::Throttle::State, state3
|
66
|
+
assert_predicate state3, :blocked?
|
67
|
+
|
68
|
+
# Delete the method on the actual throttle as it should not be called anymore until the block is lifted
|
69
|
+
class << throttle
|
70
|
+
undef :request
|
71
|
+
end
|
72
|
+
state_from_cache = cached_throttle.request(1)
|
73
|
+
assert_kind_of Pecorino::Throttle::State, state_from_cache
|
74
|
+
assert_predicate state_from_cache, :blocked?
|
75
|
+
end
|
76
|
+
|
77
|
+
test "returns the key of the contained throttle" do
|
78
|
+
store = ActiveSupport::Cache::MemoryStore.new
|
79
|
+
throttle = Pecorino::Throttle.new(key: Random.uuid, capacity: 2, over_time: 1, block_for: 10, adapter: adapter)
|
80
|
+
cached_throttle = Pecorino::CachedThrottle.new(store, throttle)
|
81
|
+
assert_equal cached_throttle.key, throttle.key
|
82
|
+
end
|
83
|
+
|
84
|
+
test "does not run block in throttled() until the block is lifted" do
|
85
|
+
store = ActiveSupport::Cache::MemoryStore.new
|
86
|
+
throttle = Pecorino::Throttle.new(key: Random.uuid, capacity: 2, over_time: 1, block_for: 10, adapter: adapter)
|
87
|
+
cached_throttle = Pecorino::CachedThrottle.new(store, throttle)
|
88
|
+
|
89
|
+
assert_equal 123, cached_throttle.throttled { 123 }
|
90
|
+
assert_equal 234, cached_throttle.throttled { 234 }
|
91
|
+
assert_nil cached_throttle.throttled { 345 }
|
92
|
+
|
93
|
+
# Delete the method on the actual throttle as it should not be called anymore until the block is lifted
|
94
|
+
class << throttle
|
95
|
+
undef :throttled
|
96
|
+
end
|
97
|
+
|
98
|
+
assert_nil cached_throttle.throttled { 345 }
|
99
|
+
end
|
100
|
+
end
|