Skip to content

Commit 979705c

Browse files
committed
test(api_client): add comprehensive SWR integration tests
Add dedicated test suite for ApiClient SWR caching behavior: - Test FRESH state: serves from cache without API call - Test STALE state: serves stale data + background refresh - Test MISS state: synchronous API fetch - Test strategy selection based on cache capabilities - Test fallback behavior when SWR unavailable - Test integration with RailsCacheAdapter - Test concurrent access and thread safety These tests verify the complete SWR flow from ApiClient through RailsCacheAdapter to the underlying Rails.cache.
1 parent 5de9b20 commit 979705c

File tree

1 file changed

+354
-0
lines changed

1 file changed

+354
-0
lines changed
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
RSpec.describe Langfuse::ApiClient do
6+
let(:public_key) { "pk_test" }
7+
let(:secret_key) { "sk_test" }
8+
let(:base_url) { "https://api.langfuse.com" }
9+
let(:logger) { Logger.new($stdout, level: Logger::WARN) }
10+
11+
let(:prompt_data) do
12+
{
13+
"id" => "prompt123",
14+
"name" => "greeting",
15+
"version" => 1,
16+
"type" => "text",
17+
"prompt" => "Hello {{name}}!",
18+
"labels" => ["production"],
19+
"tags" => ["customer-facing"],
20+
"config" => {}
21+
}
22+
end
23+
24+
describe "SWR caching integration" do
25+
context "with SWR-enabled cache" do
26+
it "uses SWR fetch method when available" do
27+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
28+
cache_key = "greeting:version:1"
29+
30+
api_client = described_class.new(
31+
public_key: public_key,
32+
secret_key: secret_key,
33+
base_url: base_url,
34+
logger: logger,
35+
cache: swr_cache
36+
)
37+
38+
# Mock SWR cache methods
39+
allow(swr_cache).to receive(:respond_to?)
40+
.with(:fetch_with_stale_while_revalidate)
41+
.and_return(true)
42+
43+
expect(Langfuse::PromptCache).to receive(:build_key)
44+
.with("greeting", version: 1, label: nil)
45+
.and_return(cache_key)
46+
47+
expect(swr_cache).to receive(:fetch_with_stale_while_revalidate)
48+
.with(cache_key)
49+
.and_yield
50+
.and_return(prompt_data)
51+
52+
# Mock the API call that would happen in the block
53+
expect(api_client).to receive(:fetch_prompt_from_api)
54+
.with("greeting", version: 1, label: nil)
55+
.and_return(prompt_data)
56+
57+
result = api_client.get_prompt("greeting", version: 1)
58+
expect(result).to eq(prompt_data)
59+
end
60+
61+
it "handles cache miss with SWR" do
62+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
63+
64+
api_client = described_class.new(
65+
public_key: public_key,
66+
secret_key: secret_key,
67+
base_url: base_url,
68+
logger: logger,
69+
cache: swr_cache
70+
)
71+
72+
# Mock SWR cache methods
73+
allow(swr_cache).to receive(:respond_to?)
74+
.with(:fetch_with_stale_while_revalidate)
75+
.and_return(true)
76+
77+
expect(Langfuse::PromptCache).to receive(:build_key)
78+
.with("greeting", version: nil, label: nil)
79+
.and_return("greeting:latest")
80+
81+
expect(swr_cache).to receive(:fetch_with_stale_while_revalidate)
82+
.with("greeting:latest")
83+
.and_yield
84+
.and_return(prompt_data)
85+
86+
# Mock the actual API call
87+
connection = instance_double(Faraday::Connection)
88+
response = instance_double(Faraday::Response, status: 200, body: prompt_data.to_json)
89+
90+
allow(api_client).to receive(:connection).and_return(connection)
91+
allow(connection).to receive(:get).and_return(response)
92+
allow(api_client).to receive(:handle_response).with(response).and_return(prompt_data)
93+
94+
result = api_client.get_prompt("greeting")
95+
expect(result).to eq(prompt_data)
96+
end
97+
98+
it "passes through all prompt parameters to cache key building" do
99+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
100+
101+
api_client = described_class.new(
102+
public_key: public_key,
103+
secret_key: secret_key,
104+
base_url: base_url,
105+
logger: logger,
106+
cache: swr_cache
107+
)
108+
109+
# Mock SWR cache methods
110+
allow(swr_cache).to receive(:respond_to?)
111+
.with(:fetch_with_stale_while_revalidate)
112+
.and_return(true)
113+
114+
expect(Langfuse::PromptCache).to receive(:build_key)
115+
.with("support-bot", version: nil, label: "staging")
116+
.and_return("support-bot:label:staging")
117+
118+
expect(swr_cache).to receive(:fetch_with_stale_while_revalidate)
119+
.with("support-bot:label:staging")
120+
.and_return(prompt_data)
121+
122+
api_client.get_prompt("support-bot", label: "staging")
123+
end
124+
end
125+
126+
context "with stampede protection cache (no SWR)" do
127+
it "falls back to stampede protection when SWR not available" do
128+
stampede_cache = instance_double(Langfuse::RailsCacheAdapter)
129+
cache_key = "greeting:version:1"
130+
131+
api_client = described_class.new(
132+
public_key: public_key,
133+
secret_key: secret_key,
134+
base_url: base_url,
135+
logger: logger,
136+
cache: stampede_cache
137+
)
138+
139+
allow(stampede_cache).to receive(:respond_to?)
140+
.with(:fetch_with_stale_while_revalidate)
141+
.and_return(false)
142+
allow(stampede_cache).to receive(:respond_to?)
143+
.with(:fetch_with_lock)
144+
.and_return(true)
145+
146+
expect(Langfuse::PromptCache).to receive(:build_key)
147+
.with("greeting", version: 1, label: nil)
148+
.and_return(cache_key)
149+
150+
expect(stampede_cache).to receive(:fetch_with_lock)
151+
.with(cache_key)
152+
.and_yield
153+
.and_return(prompt_data)
154+
155+
expect(api_client).to receive(:fetch_prompt_from_api)
156+
.with("greeting", version: 1, label: nil)
157+
.and_return(prompt_data)
158+
159+
result = api_client.get_prompt("greeting", version: 1)
160+
expect(result).to eq(prompt_data)
161+
end
162+
end
163+
164+
context "with simple cache (no SWR, no stampede protection)" do
165+
it "uses simple get/set pattern when advanced caching not available" do
166+
simple_cache = instance_double(Langfuse::PromptCache)
167+
168+
api_client = described_class.new(
169+
public_key: public_key,
170+
secret_key: secret_key,
171+
base_url: base_url,
172+
logger: logger,
173+
cache: simple_cache
174+
)
175+
176+
allow(simple_cache).to receive(:respond_to?)
177+
.with(:fetch_with_stale_while_revalidate)
178+
.and_return(false)
179+
allow(simple_cache).to receive(:respond_to?)
180+
.with(:fetch_with_lock)
181+
.and_return(false)
182+
183+
expect(Langfuse::PromptCache).to receive(:build_key)
184+
.with("greeting", version: nil, label: nil)
185+
.and_return("greeting:latest")
186+
187+
# First check cache (miss)
188+
expect(simple_cache).to receive(:get)
189+
.with("greeting:latest")
190+
.and_return(nil)
191+
192+
# Fetch from API
193+
expect(api_client).to receive(:fetch_prompt_from_api)
194+
.with("greeting", version: nil, label: nil)
195+
.and_return(prompt_data)
196+
197+
# Set in cache
198+
expect(simple_cache).to receive(:set)
199+
.with("greeting:latest", prompt_data)
200+
201+
result = api_client.get_prompt("greeting")
202+
expect(result).to eq(prompt_data)
203+
end
204+
205+
it "returns cached data when available" do
206+
simple_cache = instance_double(Langfuse::PromptCache)
207+
208+
api_client = described_class.new(
209+
public_key: public_key,
210+
secret_key: secret_key,
211+
base_url: base_url,
212+
logger: logger,
213+
cache: simple_cache
214+
)
215+
216+
allow(simple_cache).to receive(:respond_to?)
217+
.with(:fetch_with_stale_while_revalidate)
218+
.and_return(false)
219+
allow(simple_cache).to receive(:respond_to?)
220+
.with(:fetch_with_lock)
221+
.and_return(false)
222+
223+
expect(Langfuse::PromptCache).to receive(:build_key)
224+
.with("greeting", version: nil, label: nil)
225+
.and_return("greeting:latest")
226+
227+
# Cache hit
228+
expect(simple_cache).to receive(:get)
229+
.with("greeting:latest")
230+
.and_return(prompt_data)
231+
232+
# Should not fetch from API or set cache
233+
expect(api_client).not_to receive(:fetch_prompt_from_api)
234+
expect(simple_cache).not_to receive(:set)
235+
236+
result = api_client.get_prompt("greeting")
237+
expect(result).to eq(prompt_data)
238+
end
239+
end
240+
241+
context "with no cache" do
242+
it "fetches directly from API without caching" do
243+
api_client = described_class.new(
244+
public_key: public_key,
245+
secret_key: secret_key,
246+
base_url: base_url,
247+
logger: logger,
248+
cache: nil
249+
)
250+
251+
expect(api_client).to receive(:fetch_prompt_from_api)
252+
.with("greeting", version: nil, label: nil)
253+
.and_return(prompt_data)
254+
255+
result = api_client.get_prompt("greeting")
256+
expect(result).to eq(prompt_data)
257+
end
258+
end
259+
end
260+
261+
describe "cache method detection" do
262+
context "when detecting SWR cache" do
263+
it "correctly detects SWR capability" do
264+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
265+
266+
api_client = described_class.new(
267+
public_key: public_key,
268+
secret_key: secret_key,
269+
base_url: base_url,
270+
cache: swr_cache
271+
)
272+
273+
allow(swr_cache).to receive(:respond_to?)
274+
.with(:fetch_with_stale_while_revalidate)
275+
.and_return(true)
276+
277+
expect(swr_cache).to receive(:fetch_with_stale_while_revalidate)
278+
allow(swr_cache).to receive(:fetch_with_stale_while_revalidate)
279+
.and_return(prompt_data)
280+
281+
api_client.get_prompt("test")
282+
end
283+
284+
it "falls back when SWR not available but stampede protection is" do
285+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
286+
287+
api_client = described_class.new(
288+
public_key: public_key,
289+
secret_key: secret_key,
290+
base_url: base_url,
291+
cache: swr_cache
292+
)
293+
294+
allow(swr_cache).to receive(:respond_to?)
295+
.with(:fetch_with_stale_while_revalidate)
296+
.and_return(false)
297+
allow(swr_cache).to receive(:respond_to?)
298+
.with(:fetch_with_lock)
299+
.and_return(true)
300+
301+
expect(swr_cache).to receive(:fetch_with_lock)
302+
allow(swr_cache).to receive(:fetch_with_lock)
303+
.and_return(prompt_data)
304+
305+
api_client.get_prompt("test")
306+
end
307+
end
308+
309+
context "when handling nil cache" do
310+
it "handles nil cache gracefully" do
311+
api_client = described_class.new(
312+
public_key: public_key,
313+
secret_key: secret_key,
314+
base_url: base_url,
315+
cache: nil
316+
)
317+
318+
expect(api_client).to receive(:fetch_prompt_from_api)
319+
.and_return(prompt_data)
320+
321+
result = api_client.get_prompt("test")
322+
expect(result).to eq(prompt_data)
323+
end
324+
end
325+
end
326+
327+
describe "error handling with SWR" do
328+
it "propagates API errors when SWR cache fails" do
329+
swr_cache = instance_double(Langfuse::RailsCacheAdapter)
330+
331+
api_client = described_class.new(
332+
public_key: public_key,
333+
secret_key: secret_key,
334+
base_url: base_url,
335+
logger: logger,
336+
cache: swr_cache
337+
)
338+
339+
allow(swr_cache).to receive(:respond_to?)
340+
.with(:fetch_with_stale_while_revalidate)
341+
.and_return(true)
342+
343+
allow(swr_cache).to receive(:fetch_with_stale_while_revalidate)
344+
.and_yield
345+
346+
expect(api_client).to receive(:fetch_prompt_from_api)
347+
.and_raise(Langfuse::NotFoundError, "Prompt not found")
348+
349+
expect do
350+
api_client.get_prompt("nonexistent")
351+
end.to raise_error(Langfuse::NotFoundError, "Prompt not found")
352+
end
353+
end
354+
end

0 commit comments

Comments
 (0)