cache_stache 0.1.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 (45) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +231 -0
  3. data/app/assets/stylesheets/cache_stache/application.css +5 -0
  4. data/app/assets/stylesheets/cache_stache/pico.css +4 -0
  5. data/app/controllers/cache_stache/application_controller.rb +11 -0
  6. data/app/controllers/cache_stache/dashboard_controller.rb +32 -0
  7. data/app/helpers/cache_stache/application_helper.rb +37 -0
  8. data/app/views/cache_stache/dashboard/index.html.erb +154 -0
  9. data/app/views/cache_stache/dashboard/keyspace.html.erb +83 -0
  10. data/app/views/layouts/cache_stache/application.html.erb +14 -0
  11. data/config/routes.rb +6 -0
  12. data/lib/cache_stache/cache_client.rb +202 -0
  13. data/lib/cache_stache/configuration.rb +87 -0
  14. data/lib/cache_stache/engine.rb +17 -0
  15. data/lib/cache_stache/instrumentation.rb +142 -0
  16. data/lib/cache_stache/keyspace.rb +28 -0
  17. data/lib/cache_stache/rack_after_reply_middleware.rb +22 -0
  18. data/lib/cache_stache/railtie.rb +30 -0
  19. data/lib/cache_stache/stats_query.rb +89 -0
  20. data/lib/cache_stache/version.rb +5 -0
  21. data/lib/cache_stache/web.rb +69 -0
  22. data/lib/cache_stache/window_options.rb +34 -0
  23. data/lib/cache_stache.rb +37 -0
  24. data/lib/generators/cache_stache/install_generator.rb +21 -0
  25. data/lib/generators/cache_stache/templates/README +35 -0
  26. data/lib/generators/cache_stache/templates/cache_stache.rb +43 -0
  27. data/spec/cache_stache_helper.rb +148 -0
  28. data/spec/dummy_app/Rakefile +5 -0
  29. data/spec/dummy_app/app/assets/config/manifest.js +1 -0
  30. data/spec/dummy_app/config/application.rb +31 -0
  31. data/spec/dummy_app/config/boot.rb +3 -0
  32. data/spec/dummy_app/config/environment.rb +5 -0
  33. data/spec/dummy_app/config/routes.rb +7 -0
  34. data/spec/integration/dashboard_controller_spec.rb +94 -0
  35. data/spec/integration/full_cache_flow_spec.rb +202 -0
  36. data/spec/integration/instrumentation_spec.rb +259 -0
  37. data/spec/integration/rack_after_reply_spec.rb +47 -0
  38. data/spec/integration/rake_tasks_spec.rb +17 -0
  39. data/spec/spec_helper.rb +64 -0
  40. data/spec/unit/cache_client_spec.rb +278 -0
  41. data/spec/unit/configuration_spec.rb +209 -0
  42. data/spec/unit/keyspace_spec.rb +93 -0
  43. data/spec/unit/stats_query_spec.rb +367 -0
  44. data/tasks/cache_stache.rake +74 -0
  45. metadata +226 -0
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache_stache_helper"
4
+ require "json"
5
+
6
+ RSpec.describe CacheStache::CacheClient do
7
+ subject(:client) { described_class.new(config) }
8
+
9
+ let(:config) { build_test_config }
10
+
11
+ describe "#initialize" do
12
+ it "uses the provided configuration" do
13
+ expect(client.instance_variable_get(:@config)).to eq(config)
14
+ end
15
+
16
+ it "uses the default configuration when none provided" do
17
+ allow(CacheStache).to receive(:configuration).and_return(config)
18
+ default_client = described_class.new
19
+ expect(default_client.instance_variable_get(:@config)).to eq(config)
20
+ end
21
+
22
+ it "creates a Redis connection pool" do
23
+ expect(client.instance_variable_get(:@pool)).to be_a(ConnectionPool)
24
+ end
25
+ end
26
+
27
+ describe "#increment_stats" do
28
+ # Use aligned timestamp (1_700_000_000 / 300 * 300 = 1_699_999_800)
29
+ let(:bucket_ts) { (1_700_000_000 / 300) * 300 }
30
+ let(:increments) do
31
+ {
32
+ "overall:hits" => 1,
33
+ "overall:misses" => 0,
34
+ "views:hits" => 1,
35
+ "views:misses" => 0
36
+ }
37
+ end
38
+
39
+ it "increments stats for a bucket" do
40
+ client.increment_stats(bucket_ts, increments)
41
+
42
+ buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
43
+ expect(buckets.size).to eq(1)
44
+ expect(buckets.first[:stats]["overall:hits"]).to eq(1.0)
45
+ expect(buckets.first[:stats]["overall:misses"]).to eq(0.0)
46
+ expect(buckets.first[:stats]["views:hits"]).to eq(1.0)
47
+ expect(buckets.first[:stats]["views:misses"]).to eq(0.0)
48
+ end
49
+
50
+ it "accumulates stats across multiple calls" do
51
+ client.increment_stats(bucket_ts, increments)
52
+ client.increment_stats(bucket_ts, increments)
53
+
54
+ buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
55
+ expect(buckets.first[:stats]["overall:hits"]).to eq(2.0)
56
+ expect(buckets.first[:stats]["views:hits"]).to eq(2.0)
57
+ end
58
+
59
+ it "handles mixed hit and miss increments" do
60
+ client.increment_stats(bucket_ts, {"overall:hits" => 1, "overall:misses" => 0})
61
+ client.increment_stats(bucket_ts, {"overall:hits" => 0, "overall:misses" => 1})
62
+
63
+ buckets = client.fetch_buckets(bucket_ts - 100, bucket_ts + 100)
64
+ expect(buckets.first[:stats]["overall:hits"]).to eq(1.0)
65
+ expect(buckets.first[:stats]["overall:misses"]).to eq(1.0)
66
+ end
67
+
68
+ it "sets expiry on the bucket" do
69
+ client.increment_stats(bucket_ts, increments)
70
+
71
+ key = "cache_stache:v1:test:#{bucket_ts}"
72
+ ttl = cache_stache_redis.ttl(key)
73
+
74
+ expect(ttl).to be > 0
75
+ expect(ttl).to be <= config.retention_seconds
76
+ end
77
+
78
+ it "handles errors gracefully" do
79
+ # Create client, then make the pool raise errors
80
+ test_client = described_class.new(config)
81
+ pool = test_client.instance_variable_get(:@pool)
82
+ allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
83
+ allow(Rails.logger).to receive(:error)
84
+
85
+ expect { test_client.increment_stats(bucket_ts, increments) }.not_to raise_error
86
+ expect(Rails.logger).to have_received(:error).with(/Failed to increment stats/)
87
+ end
88
+ end
89
+
90
+ describe "#fetch_buckets" do
91
+ # Use a timestamp that aligns to 300-second bucket boundaries
92
+ let(:base_ts) { (1_700_000_000 / 300) * 300 } # 1_699_999_800
93
+
94
+ before do
95
+ # Create some test buckets
96
+ client.increment_stats(base_ts, {"overall:hits" => 5, "overall:misses" => 2})
97
+ client.increment_stats(base_ts + 300, {"overall:hits" => 3, "overall:misses" => 1})
98
+ client.increment_stats(base_ts + 600, {"overall:hits" => 8, "overall:misses" => 4})
99
+ end
100
+
101
+ it "fetches buckets in time range" do
102
+ from_ts = base_ts
103
+ to_ts = base_ts + 700
104
+
105
+ buckets = client.fetch_buckets(from_ts, to_ts)
106
+
107
+ expect(buckets.size).to eq(3)
108
+ expect(buckets[0][:timestamp]).to eq(base_ts)
109
+ expect(buckets[1][:timestamp]).to eq(base_ts + 300)
110
+ expect(buckets[2][:timestamp]).to eq(base_ts + 600)
111
+ end
112
+
113
+ it "returns bucket stats as floats" do
114
+ buckets = client.fetch_buckets(base_ts, base_ts + 100)
115
+
116
+ expect(buckets[0][:stats]["overall:hits"]).to eq(5.0)
117
+ expect(buckets[0][:stats]["overall:misses"]).to eq(2.0)
118
+ end
119
+
120
+ it "excludes empty buckets" do
121
+ buckets = client.fetch_buckets(base_ts + 900, base_ts + 1200)
122
+ expect(buckets).to be_empty
123
+ end
124
+
125
+ it "aligns timestamps to bucket boundaries" do
126
+ buckets = client.fetch_buckets(base_ts + 50, base_ts + 550)
127
+
128
+ expect(buckets.map { |b| b[:timestamp] }).to eq([base_ts, base_ts + 300])
129
+ end
130
+
131
+ it "handles errors gracefully" do
132
+ # Create client, then make the pool raise errors
133
+ test_client = described_class.new(config)
134
+ pool = test_client.instance_variable_get(:@pool)
135
+ allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
136
+ allow(Rails.logger).to receive(:error)
137
+
138
+ buckets = test_client.fetch_buckets(base_ts, base_ts + 300)
139
+ expect(buckets).to eq([])
140
+ expect(Rails.logger).to have_received(:error).with(/Failed to fetch buckets/)
141
+ end
142
+
143
+ it "reflects single-field increments" do
144
+ # Use a timestamp aligned to bucket boundary
145
+ ts = base_ts + 900
146
+
147
+ # Increment a single field
148
+ client.increment_stats(ts, {"overall:hits" => 1})
149
+
150
+ # Fetch the bucket containing this increment
151
+ buckets = client.fetch_buckets(ts, ts + 100)
152
+
153
+ # Verify the increment is visible
154
+ expect(buckets.size).to eq(1)
155
+ expect(buckets[0][:timestamp]).to eq(ts)
156
+ expect(buckets[0][:stats]["overall:hits"]).to eq(1.0)
157
+ end
158
+ end
159
+
160
+ describe "#store_config_metadata" do
161
+ it "stores configuration metadata in Redis" do
162
+ client.store_config_metadata
163
+
164
+ metadata = client.fetch_config_metadata
165
+
166
+ expect(metadata["bucket_seconds"]).to eq(300)
167
+ expect(metadata["retention_seconds"]).to eq(3600)
168
+ expect(metadata["updated_at"]).to be_a(Integer)
169
+ end
170
+
171
+ it "handles errors gracefully" do
172
+ # Create client, then make the pool raise errors
173
+ test_client = described_class.new(config)
174
+ pool = test_client.instance_variable_get(:@pool)
175
+ allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
176
+ allow(Rails.logger).to receive(:error)
177
+
178
+ expect { test_client.store_config_metadata }.not_to raise_error
179
+ expect(Rails.logger).to have_received(:error).with(/Failed to store config metadata/)
180
+ end
181
+ end
182
+
183
+ describe "#fetch_config_metadata" do
184
+ it "retrieves stored metadata" do
185
+ client.store_config_metadata
186
+ metadata = client.fetch_config_metadata
187
+
188
+ expect(metadata["bucket_seconds"]).to eq(300)
189
+ expect(metadata["retention_seconds"]).to eq(3600)
190
+ end
191
+
192
+ it "returns nil when metadata doesn't exist" do
193
+ metadata = client.fetch_config_metadata
194
+ expect(metadata).to be_nil
195
+ end
196
+
197
+ it "handles errors gracefully" do
198
+ # Create client, then make the pool raise errors
199
+ test_client = described_class.new(config)
200
+ pool = test_client.instance_variable_get(:@pool)
201
+ allow(pool).to receive(:with).and_raise(StandardError, "Redis error")
202
+ allow(Rails.logger).to receive(:error)
203
+
204
+ metadata = test_client.fetch_config_metadata
205
+ expect(metadata).to be_nil
206
+ expect(Rails.logger).to have_received(:error).with(/Failed to fetch config metadata/)
207
+ end
208
+ end
209
+
210
+ describe "#estimate_storage_size" do
211
+ before do
212
+ config.keyspace(:views) { match(/^views\//) }
213
+ config.keyspace(:models) { match(/model/) }
214
+ end
215
+
216
+ it "calculates estimated storage size" do
217
+ estimate = client.estimate_storage_size
218
+
219
+ expect(estimate[:max_buckets]).to be > 0
220
+ expect(estimate[:fields_per_bucket]).to eq(6) # 2 overall + 2*2 keyspaces
221
+ expect(estimate[:bytes_per_bucket]).to be > 0
222
+ expect(estimate[:total_bytes]).to be > 0
223
+ expect(estimate[:human_size]).to be_a(String)
224
+ end
225
+
226
+ it "formats bytes correctly" do
227
+ expect(client.send(:format_bytes, 500)).to eq("500 B")
228
+ expect(client.send(:format_bytes, 2048)).to eq("2.0 KB")
229
+ expect(client.send(:format_bytes, 2_097_152)).to eq("2.0 MB")
230
+ expect(client.send(:format_bytes, 2_147_483_648)).to eq("2.0 GB")
231
+ end
232
+
233
+ it "handles errors gracefully" do
234
+ allow(config).to receive(:retention_seconds).and_raise(StandardError, "Config error")
235
+ expect(Rails.logger).to receive(:error).with(/Failed to estimate storage size/)
236
+
237
+ estimate = client.estimate_storage_size
238
+ expect(estimate[:total_bytes]).to eq(0)
239
+ expect(estimate[:human_size]).to eq("Unknown")
240
+ end
241
+ end
242
+
243
+ describe "private methods" do
244
+ describe "#bucket_key" do
245
+ it "generates correct bucket key format" do
246
+ key = client.send(:bucket_key, 1_700_000_000)
247
+ expect(key).to eq("cache_stache:v1:test:1700000000")
248
+ end
249
+ end
250
+
251
+ describe "#align_to_bucket" do
252
+ it "aligns timestamp to bucket boundary" do
253
+ aligned = client.send(:align_to_bucket, 1_700_000_123)
254
+ expect(aligned).to eq(1_700_000_100) # 123 aligned to 300s bucket
255
+ end
256
+ end
257
+
258
+ describe "#extract_timestamp_from_key" do
259
+ it "extracts timestamp from key" do
260
+ ts = client.send(:extract_timestamp_from_key, "cache_stache:v1:test:1700000000")
261
+ expect(ts).to eq(1_700_000_000)
262
+ end
263
+ end
264
+
265
+ describe "#bucket_keys_in_range" do
266
+ it "generates all bucket keys in range" do
267
+ # Use aligned timestamps (1_699_999_800 is divisible by 300)
268
+ aligned_base = (1_700_000_000 / 300) * 300
269
+ keys = client.send(:bucket_keys_in_range, aligned_base, aligned_base + 700)
270
+ expect(keys).to eq([
271
+ "cache_stache:v1:test:#{aligned_base}",
272
+ "cache_stache:v1:test:#{aligned_base + 300}",
273
+ "cache_stache:v1:test:#{aligned_base + 600}"
274
+ ])
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache_stache_helper"
4
+
5
+ RSpec.describe CacheStache::Configuration do
6
+ subject(:config) { described_class.new }
7
+
8
+ describe "#initialize" do
9
+ let(:default_redis_url) { "redis://cache-stache.test/0" }
10
+
11
+ before do
12
+ allow(ENV).to receive(:fetch).and_call_original
13
+ allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return(default_redis_url)
14
+ end
15
+
16
+ it { expect(config.bucket_seconds).to eq(5.minutes.to_i) }
17
+ it { expect(config.retention_seconds).to eq(7.days.to_i) }
18
+ it { expect(config.sample_rate).to eq(1.0) }
19
+ it { expect(config.enabled).to be(true) }
20
+ it { expect(config.use_rack_after_reply).to be(false) }
21
+ it { expect(config.redis_url).to eq(default_redis_url) }
22
+ it { expect(config.redis_pool_size).to eq(5) }
23
+ it { expect(config.keyspaces).to eq([]) }
24
+ end
25
+
26
+ describe "#keyspace" do
27
+ it "adds a keyspace with the given name" do
28
+ config.keyspace(:views) do
29
+ label "View Fragments"
30
+ match(/^views\//)
31
+ end
32
+
33
+ expect(config.keyspaces.size).to eq(1)
34
+ expect(config.keyspaces.first.name).to eq(:views)
35
+ expect(config.keyspaces.first.label).to eq("View Fragments")
36
+ end
37
+
38
+ it "uses humanized name as default label" do
39
+ config.keyspace(:search_results) do
40
+ match(/search/)
41
+ end
42
+
43
+ expect(config.keyspaces.first.label).to eq("Search results")
44
+ end
45
+
46
+ it "validates the keyspace requires a pattern" do
47
+ expect do
48
+ config.keyspace(:invalid) do
49
+ label "Invalid"
50
+ # No match pattern
51
+ end
52
+ end.to raise_error(CacheStache::Error, /requires a match pattern/)
53
+ end
54
+
55
+ it "validates the pattern must be a Regexp" do
56
+ expect do
57
+ config.keyspace(:invalid) do
58
+ match "not a regex"
59
+ end
60
+ end.to raise_error(CacheStache::Error, /requires a Regexp argument/)
61
+ end
62
+
63
+ it "raises error for duplicate keyspace names" do
64
+ config.keyspace(:views) do
65
+ match(/^views\//)
66
+ end
67
+
68
+ expect do
69
+ config.keyspace(:views) do
70
+ match(/view/)
71
+ end
72
+ end.to raise_error(CacheStache::Error, /already defined/)
73
+ end
74
+
75
+ it "accepts a block without a label" do
76
+ config.keyspace(:test) do
77
+ match(/test/)
78
+ end
79
+
80
+ expect(config.keyspaces.first.label).to eq("Test")
81
+ end
82
+
83
+ it "stores the pattern on the keyspace" do
84
+ config.keyspace(:views) do
85
+ match(/^views\//)
86
+ end
87
+
88
+ expect(config.keyspaces.first.pattern).to eq(/^views\//)
89
+ end
90
+ end
91
+
92
+ describe "#matching_keyspaces" do
93
+ before do
94
+ config.keyspace(:views) do
95
+ match(/^views\//)
96
+ end
97
+
98
+ config.keyspace(:models) do
99
+ match(/community/)
100
+ end
101
+
102
+ config.keyspace(:search) do
103
+ match(/search/)
104
+ end
105
+ end
106
+
107
+ it "returns keyspaces matching the key" do
108
+ matches = config.matching_keyspaces("views/product_123")
109
+ expect(matches.map(&:name)).to eq([:views])
110
+ end
111
+
112
+ it "returns multiple keyspaces when key matches multiple patterns" do
113
+ matches = config.matching_keyspaces("views/community_search")
114
+ expect(matches.map(&:name)).to contain_exactly(:views, :models, :search)
115
+ end
116
+
117
+ it "returns empty array when no keyspaces match" do
118
+ matches = config.matching_keyspaces("unmatched_key")
119
+ expect(matches).to eq([])
120
+ end
121
+
122
+ it "caches results for the same key" do
123
+ # First call
124
+ config.matching_keyspaces("views/test")
125
+
126
+ # Modify keyspace to prove caching
127
+ config.keyspaces.first.instance_variable_set(:@pattern, /never_match/)
128
+
129
+ # Should still return cached result
130
+ matches = config.matching_keyspaces("views/test")
131
+ expect(matches.map(&:name)).to eq([:views])
132
+ end
133
+ end
134
+
135
+ describe "#validate!" do
136
+ before do
137
+ config.redis_url = "redis://localhost:6379/0"
138
+ end
139
+
140
+ it "requires redis_url" do
141
+ config.redis_url = nil
142
+ expect { config.validate! }.to raise_error(CacheStache::Error, /redis_url must be configured/)
143
+ end
144
+
145
+ it "requires redis_pool_size to be positive" do
146
+ config.redis_pool_size = 0
147
+ expect { config.validate! }.to raise_error(CacheStache::Error, /redis_pool_size must be positive/)
148
+ end
149
+
150
+ it "validates bucket_seconds is positive" do
151
+ config.bucket_seconds = 0
152
+ expect { config.validate! }.to raise_error(CacheStache::Error, /bucket_seconds must be positive/)
153
+ end
154
+
155
+ it "validates retention_seconds is positive" do
156
+ config.retention_seconds = -1
157
+ expect { config.validate! }.to raise_error(CacheStache::Error, /retention_seconds must be positive/)
158
+ end
159
+
160
+ it "validates sample_rate is between 0 and 1" do
161
+ config.sample_rate = 1.5
162
+ expect { config.validate! }.to raise_error(CacheStache::Error, /sample_rate must be between 0 and 1/)
163
+ end
164
+
165
+ it "validates all keyspaces" do
166
+ # Create a keyspace with a valid pattern first
167
+ config.keyspace(:invalid) do
168
+ match(/test/)
169
+ end
170
+
171
+ # Then corrupt it by setting pattern to nil
172
+ config.instance_variable_get(:@keyspaces).last.instance_variable_set(:@pattern, nil)
173
+
174
+ expect { config.validate! }.to raise_error(CacheStache::Error, /requires a match pattern/)
175
+ end
176
+
177
+ it "warns when retention doesn't divide evenly by bucket size" do
178
+ config.bucket_seconds = 7.minutes.to_i
179
+ config.retention_seconds = 1.hour.to_i
180
+
181
+ expect(Rails.logger).to receive(:warn).with(/does not divide evenly/)
182
+ config.validate!
183
+ end
184
+
185
+ it "passes validation with valid configuration" do
186
+ config.keyspace(:views) do
187
+ match(/^views\//)
188
+ end
189
+
190
+ expect { config.validate! }.not_to raise_error
191
+ end
192
+ end
193
+
194
+ describe "#rails_env" do
195
+ it "returns RAILS_ENV when set" do
196
+ allow(ENV).to receive(:fetch).and_call_original
197
+ allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return("redis://cache-stache.test/0")
198
+ allow(ENV).to receive(:fetch).with("RAILS_ENV", "development").and_return("production")
199
+ expect(config.rails_env).to eq("production")
200
+ end
201
+
202
+ it "defaults to development when RAILS_ENV is not set" do
203
+ allow(ENV).to receive(:fetch).and_call_original
204
+ allow(ENV).to receive(:fetch).with("CACHE_STACHE_REDIS_URL").and_return("redis://cache-stache.test/0")
205
+ allow(ENV).to receive(:fetch).with("RAILS_ENV", "development").and_return("development")
206
+ expect(config.rails_env).to eq("development")
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cache_stache_helper"
4
+
5
+ RSpec.describe CacheStache::Keyspace do
6
+ subject(:keyspace) { described_class.new(:views) }
7
+
8
+ describe "#initialize" do
9
+ it { expect(keyspace.name).to eq(:views) }
10
+ it { expect(keyspace.label).to eq("Views") }
11
+ it { expect(keyspace.pattern).to be_nil }
12
+ end
13
+
14
+ describe "#match?" do
15
+ context "with a valid pattern" do
16
+ before do
17
+ keyspace.pattern = /^views\//
18
+ end
19
+
20
+ it "returns true when key matches" do
21
+ expect(keyspace.match?("views/product_123")).to be(true)
22
+ end
23
+
24
+ it "returns false when key doesn't match" do
25
+ expect(keyspace.match?("community/456")).to be(false)
26
+ end
27
+ end
28
+
29
+ context "without a pattern" do
30
+ it "returns false" do
31
+ expect(keyspace.match?("any_key")).to be(false)
32
+ end
33
+ end
34
+
35
+ context "with various regex patterns" do
36
+ it "handles start anchor" do
37
+ keyspace.pattern = /^views\/\w+_\d+$/
38
+ expect(keyspace.match?("views/product_123")).to be(true)
39
+ expect(keyspace.match?("views/invalid")).to be(false)
40
+ end
41
+
42
+ it "handles partial matching" do
43
+ keyspace.pattern = /search/
44
+ expect(keyspace.match?("pages/search/results")).to be(true)
45
+ expect(keyspace.match?("pages/home")).to be(false)
46
+ end
47
+
48
+ it "handles alternation" do
49
+ keyspace.pattern = %r{/(community|unit|lease)/}
50
+ expect(keyspace.match?("models/community/123")).to be(true)
51
+ expect(keyspace.match?("models/unit/456")).to be(true)
52
+ expect(keyspace.match?("models/account/789")).to be(false)
53
+ end
54
+ end
55
+ end
56
+
57
+ describe "#validate!" do
58
+ it "raises error when pattern is not set" do
59
+ expect do
60
+ keyspace.validate!
61
+ end.to raise_error(CacheStache::Error, /Keyspace views requires a match pattern/)
62
+ end
63
+
64
+ it "raises error when pattern is not a Regexp" do
65
+ keyspace.pattern = "not a regex"
66
+ expect do
67
+ keyspace.validate!
68
+ end.to raise_error(CacheStache::Error, /match pattern must be a Regexp/)
69
+ end
70
+
71
+ it "passes validation when pattern is a valid Regexp" do
72
+ keyspace.pattern = /test/
73
+ expect { keyspace.validate! }.not_to raise_error
74
+ end
75
+ end
76
+
77
+ describe "label humanization" do
78
+ it "humanizes snake_case names" do
79
+ ks = described_class.new(:search_results)
80
+ expect(ks.label).to eq("Search results")
81
+ end
82
+
83
+ it "humanizes single word names" do
84
+ ks = described_class.new(:profiles)
85
+ expect(ks.label).to eq("Profiles")
86
+ end
87
+
88
+ it "can be overridden" do
89
+ keyspace.label = "Custom Label"
90
+ expect(keyspace.label).to eq("Custom Label")
91
+ end
92
+ end
93
+ end