togglecraft 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::Cache do
|
|
4
|
+
let(:cache) { described_class.new(ttl: 2) } # 2 seconds for faster tests
|
|
5
|
+
|
|
6
|
+
after do
|
|
7
|
+
cache.destroy
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe '#initialize' do
|
|
11
|
+
it 'creates a cache with default memory adapter' do
|
|
12
|
+
cache = described_class.new
|
|
13
|
+
expect(cache.adapter).to be_a(ToggleCraft::CacheAdapters::MemoryAdapter)
|
|
14
|
+
cache.destroy
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'uses default TTL of 300 seconds' do
|
|
18
|
+
cache = described_class.new
|
|
19
|
+
expect(cache.default_ttl).to eq(300)
|
|
20
|
+
cache.destroy
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'accepts custom TTL' do
|
|
24
|
+
cache = described_class.new(ttl: 600)
|
|
25
|
+
expect(cache.default_ttl).to eq(600)
|
|
26
|
+
cache.destroy
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'accepts custom adapter' do
|
|
30
|
+
custom_adapter = ToggleCraft::CacheAdapters::MemoryAdapter.new
|
|
31
|
+
cache = described_class.new(adapter: custom_adapter)
|
|
32
|
+
expect(cache.adapter).to eq(custom_adapter)
|
|
33
|
+
cache.destroy
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#set and #get' do
|
|
38
|
+
it 'stores and retrieves values' do
|
|
39
|
+
cache.set('key', 'value')
|
|
40
|
+
expect(cache.get('key')).to eq('value')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'returns nil for non-existent keys' do
|
|
44
|
+
expect(cache.get('missing')).to be_nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'stores different types of values' do
|
|
48
|
+
cache.set('string', 'text')
|
|
49
|
+
cache.set('number', 123)
|
|
50
|
+
cache.set('hash', { foo: 'bar' })
|
|
51
|
+
cache.set('array', [1, 2, 3])
|
|
52
|
+
cache.set('boolean', true)
|
|
53
|
+
|
|
54
|
+
expect(cache.get('string')).to eq('text')
|
|
55
|
+
expect(cache.get('number')).to eq(123)
|
|
56
|
+
expect(cache.get('hash')).to eq({ foo: 'bar' })
|
|
57
|
+
expect(cache.get('array')).to eq([1, 2, 3])
|
|
58
|
+
expect(cache.get('boolean')).to be true
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe 'TTL and expiration' do
|
|
63
|
+
it 'expires entries after TTL' do
|
|
64
|
+
cache.set('key', 'value')
|
|
65
|
+
expect(cache.get('key')).to eq('value')
|
|
66
|
+
|
|
67
|
+
# Wait for expiration (TTL is 2 seconds in test)
|
|
68
|
+
# Sleep a bit longer to account for timing precision
|
|
69
|
+
sleep 2.5
|
|
70
|
+
|
|
71
|
+
expect(cache.get('key')).to be_nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'allows custom TTL per entry' do
|
|
75
|
+
cache.set('short', 'value1', ttl: 1)
|
|
76
|
+
cache.set('long', 'value2', ttl: 10)
|
|
77
|
+
|
|
78
|
+
# Sleep a bit longer to ensure expiration
|
|
79
|
+
sleep 1.5
|
|
80
|
+
|
|
81
|
+
expect(cache.get('short')).to be_nil
|
|
82
|
+
expect(cache.get('long')).to eq('value2')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#delete' do
|
|
87
|
+
it 'removes an entry' do
|
|
88
|
+
cache.set('key', 'value')
|
|
89
|
+
expect(cache.delete('key')).to be true
|
|
90
|
+
expect(cache.get('key')).to be_nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'returns false for non-existent keys' do
|
|
94
|
+
expect(cache.delete('missing')).to be false
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe '#clear' do
|
|
99
|
+
it 'removes all entries' do
|
|
100
|
+
cache.set('key1', 'value1')
|
|
101
|
+
cache.set('key2', 'value2')
|
|
102
|
+
cache.set('key3', 'value3')
|
|
103
|
+
|
|
104
|
+
cache.clear
|
|
105
|
+
|
|
106
|
+
expect(cache.get('key1')).to be_nil
|
|
107
|
+
expect(cache.get('key2')).to be_nil
|
|
108
|
+
expect(cache.get('key3')).to be_nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe '#has?' do
|
|
113
|
+
it 'returns true for existing non-expired entries' do
|
|
114
|
+
cache.set('key', 'value')
|
|
115
|
+
expect(cache.has?('key')).to be true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'returns false for non-existent keys' do
|
|
119
|
+
expect(cache.has?('missing')).to be false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns false for expired entries' do
|
|
123
|
+
cache.set('key', 'value', ttl: 1)
|
|
124
|
+
sleep 1.5
|
|
125
|
+
expect(cache.has?('key')).to be false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'returns false after deletion' do
|
|
129
|
+
cache.set('key', 'value')
|
|
130
|
+
cache.delete('key')
|
|
131
|
+
expect(cache.has?('key')).to be false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe '#clear_by_prefix' do
|
|
136
|
+
it 'deletes all keys matching prefix' do
|
|
137
|
+
cache.set('user:123', 'data1')
|
|
138
|
+
cache.set('user:456', 'data2')
|
|
139
|
+
cache.set('session:789', 'data3')
|
|
140
|
+
|
|
141
|
+
cache.clear_by_prefix('user:')
|
|
142
|
+
|
|
143
|
+
expect(cache.get('user:123')).to be_nil
|
|
144
|
+
expect(cache.get('user:456')).to be_nil
|
|
145
|
+
expect(cache.get('session:789')).to eq('data3')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'does not delete keys not matching prefix' do
|
|
149
|
+
cache.set('flag:test', 'data1')
|
|
150
|
+
cache.set('eval:test', 'data2')
|
|
151
|
+
|
|
152
|
+
cache.clear_by_prefix('flag:')
|
|
153
|
+
|
|
154
|
+
expect(cache.get('flag:test')).to be_nil
|
|
155
|
+
expect(cache.get('eval:test')).to eq('data2')
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '#all_flags' do
|
|
160
|
+
it 'returns all cached flags' do
|
|
161
|
+
cache.set('flag:feature1', { enabled: true })
|
|
162
|
+
cache.set('flag:feature2', { enabled: false })
|
|
163
|
+
cache.set('other:key', 'not a flag')
|
|
164
|
+
|
|
165
|
+
flags = cache.all_flags
|
|
166
|
+
expect(flags).to eq({
|
|
167
|
+
'feature1' => { enabled: true },
|
|
168
|
+
'feature2' => { enabled: false }
|
|
169
|
+
})
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it 'excludes expired flags' do
|
|
173
|
+
cache.set('flag:feature1', { enabled: true })
|
|
174
|
+
cache.set('flag:feature2', { enabled: false }, ttl: 1)
|
|
175
|
+
|
|
176
|
+
sleep 1.5
|
|
177
|
+
|
|
178
|
+
flags = cache.all_flags
|
|
179
|
+
expect(flags).to eq({
|
|
180
|
+
'feature1' => { enabled: true }
|
|
181
|
+
})
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'returns empty hash when no flags exist' do
|
|
185
|
+
expect(cache.all_flags).to eq({})
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
describe '#set_all_flags' do
|
|
190
|
+
it 'stores multiple flags at once' do
|
|
191
|
+
flags = {
|
|
192
|
+
'feature1' => { enabled: true },
|
|
193
|
+
'feature2' => { enabled: false },
|
|
194
|
+
'feature3' => { enabled: true }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
cache.set_all_flags(flags)
|
|
198
|
+
|
|
199
|
+
expect(cache.get('flag:feature1')).to eq({ enabled: true })
|
|
200
|
+
expect(cache.get('flag:feature2')).to eq({ enabled: false })
|
|
201
|
+
expect(cache.get('flag:feature3')).to eq({ enabled: true })
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it 'accepts custom TTL for all flags' do
|
|
205
|
+
flags = {
|
|
206
|
+
'feature1' => { enabled: true },
|
|
207
|
+
'feature2' => { enabled: false }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
cache.set_all_flags(flags, ttl: 1)
|
|
211
|
+
|
|
212
|
+
sleep 1.5
|
|
213
|
+
|
|
214
|
+
expect(cache.get('flag:feature1')).to be_nil
|
|
215
|
+
expect(cache.get('flag:feature2')).to be_nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'overwrites existing flags' do
|
|
219
|
+
cache.set('flag:feature1', { enabled: false })
|
|
220
|
+
cache.set_all_flags({ 'feature1' => { enabled: true } })
|
|
221
|
+
|
|
222
|
+
expect(cache.get('flag:feature1')).to eq({ enabled: true })
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe 'automatic cleanup' do
|
|
227
|
+
it 'removes expired entries automatically' do
|
|
228
|
+
cache.set('key1', 'value1', ttl: 1)
|
|
229
|
+
cache.set('key2', 'value2', ttl: 10)
|
|
230
|
+
|
|
231
|
+
# Wait for expiration and cleanup cycle
|
|
232
|
+
sleep 1.5
|
|
233
|
+
|
|
234
|
+
# Trigger cleanup manually for testing
|
|
235
|
+
cache.send(:cleanup_expired)
|
|
236
|
+
|
|
237
|
+
# Adapter should not have the expired key
|
|
238
|
+
expect(cache.adapter.has?('key1')).to be false
|
|
239
|
+
expect(cache.get('key2')).to eq('value2')
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe '#destroy' do
|
|
244
|
+
it 'stops cleanup thread and clears cache' do
|
|
245
|
+
cache.set('key', 'value')
|
|
246
|
+
cache.destroy
|
|
247
|
+
|
|
248
|
+
expect(cache.get('key')).to be_nil
|
|
249
|
+
expect(cache.instance_variable_get(:@cleanup_thread)).to be_nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'is safe to call multiple times' do
|
|
253
|
+
expect { cache.destroy }.not_to raise_error
|
|
254
|
+
expect { cache.destroy }.not_to raise_error
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
describe 'concurrent access' do
|
|
259
|
+
it 'handles concurrent reads and writes safely' do
|
|
260
|
+
threads = 10.times.map do |i|
|
|
261
|
+
Thread.new do
|
|
262
|
+
10.times do |j|
|
|
263
|
+
key = "thread_#{i}_flag_#{j}"
|
|
264
|
+
value = { enabled: (j % 2).zero? }
|
|
265
|
+
cache.set(key, value)
|
|
266
|
+
expect(cache.get(key)).to eq(value)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
threads.each(&:join)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|