Skip to content

Commit 3ceb813

Browse files
committed
[EnvironmentCookbookVersionsEndpoint] Test #depsolve
Add comprehensive unit tests for the depsolve dependency solver: - empty versions returns nil and sets @last_constraint_failure - empty unsolved list returns desired_versions unchanged - single cookbook with no dependencies resolves to latest - cookbook with satisfiable dependency resolves both - missing dependency cookbook returns nil and sets @last_missing_dep - backtracking to older version when newest has unsatisfiable dep - environment constraints applied to dependency versions - dependency's own version constraint filters available versions - pre-tracked dependency not narrowed to single version (known failure) - unsatisfiable dep constraint (all versions filtered) returns nil - cache accumulates entries for all resolved cookbooks - multiple independent unsolved cookbooks resolve to latest - diamond dependency (two cookbooks sharing a dep) - conflicting dependency constraints returns nil - deep dependency chain (A -> B -> C) - multiple backtracking steps (v3, v2 fail, v1 succeeds) - cookbook with missing metadata key uses fallback Signed-off-by: David Crosby <dcrosby@fb.com>
1 parent c101b8d commit 3ceb813

File tree

1 file changed

+339
-0
lines changed

1 file changed

+339
-0
lines changed

spec/endpoints/environment_cookbook_versions_endpoint_spec.rb

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
require "uri" unless defined?(URI)
12
require "chef_zero/endpoints/environment_cookbook_versions_endpoint"
23

34
describe ChefZero::Endpoints::EnvironmentCookbookVersionsEndpoint do
@@ -58,4 +59,342 @@
5859
expect(versions).to eq(original)
5960
end
6061
end
62+
63+
describe "#depsolve" do
64+
let(:org_prefix) { %w{organizations testorg} }
65+
let(:request) { double("request", rest_path: org_prefix + %w{environments _default cookbook_versions}) }
66+
let(:data_store) { double("data_store") }
67+
68+
before do
69+
allow(server).to receive(:data_store).and_return(data_store)
70+
end
71+
72+
def cookbook_json(name, version, dependencies = {})
73+
FFI_Yajl::Encoder.encode({
74+
"metadata" => {
75+
"name" => name,
76+
"version" => version,
77+
"dependencies" => dependencies,
78+
},
79+
})
80+
end
81+
82+
context "base cases" do
83+
it "returns [nil, nil] when a cookbook has empty versions" do
84+
desired = { "apache" => [] }
85+
result, _cache = endpoint.depsolve(request, ["apache"], desired, {})
86+
expect(result).to be_nil
87+
end
88+
89+
it "sets @last_constraint_failure when a cookbook has empty versions" do
90+
desired = { "apache" => [] }
91+
endpoint.depsolve(request, ["apache"], desired, {})
92+
expect(endpoint.instance_variable_get(:@last_constraint_failure)).to eq("apache")
93+
end
94+
95+
it "returns desired_versions when unsolved list is empty" do
96+
desired = { "apache" => ["1.0.0"] }
97+
result, _cache = endpoint.depsolve(request, [], desired, {})
98+
expect(result).to eq(desired)
99+
end
100+
101+
it "resolves a cookbook with no metadata key" do
102+
desired = { "minimal" => ["1.0.0"] }
103+
allow(data_store).to receive(:get)
104+
.with(org_prefix + ["cookbooks", "minimal", "1.0.0"], request)
105+
.and_return(FFI_Yajl::Encoder.encode({}))
106+
107+
result, cache = endpoint.depsolve(request, ["minimal"], desired, {})
108+
expect(result["minimal"]).to eq(["1.0.0"])
109+
expect(cache["minimal"]).to have_key("1.0.0")
110+
end
111+
end
112+
113+
context "simple resolution" do
114+
it "resolves a single cookbook with no dependencies" do
115+
desired = { "apache" => ["1.0.0", "2.0.0"] }
116+
allow(data_store).to receive(:get)
117+
.with(org_prefix + ["cookbooks", "apache", "2.0.0"], request)
118+
.and_return(cookbook_json("apache", "2.0.0"))
119+
allow(data_store).to receive(:get)
120+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
121+
.and_return(cookbook_json("apache", "1.0.0"))
122+
123+
result, cache = endpoint.depsolve(request, ["apache"], desired, {})
124+
expect(result["apache"]).to eq(["2.0.0"])
125+
expect(cache["apache"]["2.0.0"]).to be_a(Hash)
126+
end
127+
128+
it "resolves a cookbook with a satisfiable dependency" do
129+
desired = { "apache" => ["1.0.0"] }
130+
allow(data_store).to receive(:get)
131+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
132+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 1.0.0" }))
133+
allow(data_store).to receive(:exists_dir?)
134+
.with(org_prefix + %w{cookbooks mysql})
135+
.and_return(true)
136+
allow(data_store).to receive(:list)
137+
.with(org_prefix + %w{cookbooks mysql})
138+
.and_return(["1.0.0", "2.0.0"])
139+
allow(data_store).to receive(:get)
140+
.with(org_prefix + ["cookbooks", "mysql", "2.0.0"], request)
141+
.and_return(cookbook_json("mysql", "2.0.0"))
142+
allow(data_store).to receive(:get)
143+
.with(org_prefix + ["cookbooks", "mysql", "1.0.0"], request)
144+
.and_return(cookbook_json("mysql", "1.0.0"))
145+
146+
result, _cache = endpoint.depsolve(request, ["apache"], desired, {})
147+
expect(result["apache"]).to eq(["1.0.0"])
148+
expect(result["mysql"]).to eq(["2.0.0"])
149+
end
150+
151+
it "returns [nil, nil] when a dependency cookbook does not exist" do
152+
desired = { "apache" => ["1.0.0"] }
153+
allow(data_store).to receive(:get)
154+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
155+
.and_return(cookbook_json("apache", "1.0.0", { "missing" => ">= 0.0.0" }))
156+
allow(data_store).to receive(:exists_dir?)
157+
.with(org_prefix + %w{cookbooks missing})
158+
.and_return(false)
159+
160+
result, _cache = endpoint.depsolve(request, ["apache"], desired, {})
161+
expect(result).to be_nil
162+
expect(endpoint.instance_variable_get(:@last_missing_dep)).to eq("missing")
163+
end
164+
end
165+
166+
context "constraint filtering" do
167+
it "applies environment constraints to dependency versions" do
168+
desired = { "apache" => ["1.0.0"] }
169+
env_constraints = { "mysql" => "= 1.0.0" }
170+
171+
allow(data_store).to receive(:get)
172+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
173+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 0.0.0" }))
174+
allow(data_store).to receive(:exists_dir?)
175+
.with(org_prefix + %w{cookbooks mysql})
176+
.and_return(true)
177+
allow(data_store).to receive(:list)
178+
.with(org_prefix + %w{cookbooks mysql})
179+
.and_return(["1.0.0", "2.0.0"])
180+
allow(data_store).to receive(:get)
181+
.with(org_prefix + ["cookbooks", "mysql", "1.0.0"], request)
182+
.and_return(cookbook_json("mysql", "1.0.0"))
183+
184+
result, _cache = endpoint.depsolve(request, ["apache"], desired, env_constraints)
185+
expect(result["mysql"]).to eq(["1.0.0"])
186+
end
187+
188+
it "filters dependency versions by the dependency's own constraint" do
189+
desired = { "apache" => ["1.0.0"] }
190+
allow(data_store).to receive(:get)
191+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
192+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 2.0.0" }))
193+
allow(data_store).to receive(:exists_dir?)
194+
.with(org_prefix + %w{cookbooks mysql})
195+
.and_return(true)
196+
allow(data_store).to receive(:list)
197+
.with(org_prefix + %w{cookbooks mysql})
198+
.and_return(["1.0.0", "2.0.0"])
199+
allow(data_store).to receive(:get)
200+
.with(org_prefix + ["cookbooks", "mysql", "2.0.0"], request)
201+
.and_return(cookbook_json("mysql", "2.0.0"))
202+
203+
result, _cache = endpoint.depsolve(request, ["apache"], desired, {})
204+
expect(result["mysql"]).to eq(["2.0.0"])
205+
end
206+
207+
it "returns nil when dep constraint filters out all available versions" do
208+
desired = { "apache" => ["1.0.0"] }
209+
allow(data_store).to receive(:get)
210+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
211+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 5.0.0" }))
212+
allow(data_store).to receive(:exists_dir?)
213+
.with(org_prefix + %w{cookbooks mysql})
214+
.and_return(true)
215+
allow(data_store).to receive(:list)
216+
.with(org_prefix + %w{cookbooks mysql})
217+
.and_return(["1.0.0", "2.0.0"])
218+
219+
result, _cache = endpoint.depsolve(request, ["apache"], desired, {})
220+
expect(result).to be_nil
221+
end
222+
end
223+
224+
context "backtracking" do
225+
it "falls back to an older version when newest has unsatisfiable dep" do
226+
desired = { "web" => ["1.0.0", "2.0.0"] }
227+
# v2.0.0 depends on "ghost" which doesn't exist
228+
allow(data_store).to receive(:get)
229+
.with(org_prefix + ["cookbooks", "web", "2.0.0"], request)
230+
.and_return(cookbook_json("web", "2.0.0", { "ghost" => ">= 0.0.0" }))
231+
allow(data_store).to receive(:exists_dir?)
232+
.with(org_prefix + %w{cookbooks ghost})
233+
.and_return(false)
234+
# v1.0.0 has no deps
235+
allow(data_store).to receive(:get)
236+
.with(org_prefix + ["cookbooks", "web", "1.0.0"], request)
237+
.and_return(cookbook_json("web", "1.0.0"))
238+
239+
result, _cache = endpoint.depsolve(request, ["web"], desired, {})
240+
expect(result["web"]).to eq(["1.0.0"])
241+
end
242+
243+
it "backtracks through multiple failing versions" do
244+
desired = { "web" => ["1.0.0", "2.0.0", "3.0.0"] }
245+
allow(data_store).to receive(:get)
246+
.with(org_prefix + ["cookbooks", "web", "3.0.0"], request)
247+
.and_return(cookbook_json("web", "3.0.0", { "missing_a" => ">= 0.0.0" }))
248+
allow(data_store).to receive(:exists_dir?)
249+
.with(org_prefix + %w{cookbooks missing_a})
250+
.and_return(false)
251+
allow(data_store).to receive(:get)
252+
.with(org_prefix + ["cookbooks", "web", "2.0.0"], request)
253+
.and_return(cookbook_json("web", "2.0.0", { "missing_b" => ">= 0.0.0" }))
254+
allow(data_store).to receive(:exists_dir?)
255+
.with(org_prefix + %w{cookbooks missing_b})
256+
.and_return(false)
257+
allow(data_store).to receive(:get)
258+
.with(org_prefix + ["cookbooks", "web", "1.0.0"], request)
259+
.and_return(cookbook_json("web", "1.0.0"))
260+
261+
result, _cache = endpoint.depsolve(request, ["web"], desired, {})
262+
expect(result["web"]).to eq(["1.0.0"])
263+
end
264+
end
265+
266+
context "multiple cookbooks and dependencies" do
267+
it "populates cache entries for all resolved cookbooks" do
268+
desired = { "apache" => ["1.0.0"] }
269+
allow(data_store).to receive(:get)
270+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
271+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 1.0.0" }))
272+
allow(data_store).to receive(:exists_dir?)
273+
.with(org_prefix + %w{cookbooks mysql})
274+
.and_return(true)
275+
allow(data_store).to receive(:list)
276+
.with(org_prefix + %w{cookbooks mysql})
277+
.and_return(["1.0.0"])
278+
allow(data_store).to receive(:get)
279+
.with(org_prefix + ["cookbooks", "mysql", "1.0.0"], request)
280+
.and_return(cookbook_json("mysql", "1.0.0"))
281+
282+
_result, cache = endpoint.depsolve(request, ["apache"], desired, {})
283+
expect(cache["apache"]).to have_key("1.0.0")
284+
expect(cache["apache"]["1.0.0"]["metadata"]["name"]).to eq("apache")
285+
expect(cache["mysql"]).to have_key("1.0.0")
286+
expect(cache["mysql"]["1.0.0"]["metadata"]["name"]).to eq("mysql")
287+
end
288+
289+
it "resolves multiple independent unsolved cookbooks" do
290+
desired = {
291+
"apache" => ["1.0.0", "2.0.0"],
292+
"nginx" => ["3.0.0", "4.0.0"],
293+
}
294+
allow(data_store).to receive(:get)
295+
.with(org_prefix + ["cookbooks", "apache", "2.0.0"], request)
296+
.and_return(cookbook_json("apache", "2.0.0"))
297+
allow(data_store).to receive(:get)
298+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
299+
.and_return(cookbook_json("apache", "1.0.0"))
300+
allow(data_store).to receive(:get)
301+
.with(org_prefix + ["cookbooks", "nginx", "4.0.0"], request)
302+
.and_return(cookbook_json("nginx", "4.0.0"))
303+
allow(data_store).to receive(:get)
304+
.with(org_prefix + ["cookbooks", "nginx", "3.0.0"], request)
305+
.and_return(cookbook_json("nginx", "3.0.0"))
306+
307+
result, cache = endpoint.depsolve(request, %w{apache nginx}, desired, {})
308+
expect(result["apache"]).to eq(["2.0.0"])
309+
expect(result["nginx"]).to eq(["4.0.0"])
310+
expect(cache.keys).to contain_exactly("apache", "nginx")
311+
end
312+
313+
it "resolves a diamond dependency (two cookbooks sharing a dep)" do
314+
desired = {
315+
"apache" => ["1.0.0"],
316+
"nginx" => ["1.0.0"],
317+
}
318+
allow(data_store).to receive(:get)
319+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
320+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 1.0.0" }))
321+
allow(data_store).to receive(:get)
322+
.with(org_prefix + ["cookbooks", "nginx", "1.0.0"], request)
323+
.and_return(cookbook_json("nginx", "1.0.0", { "mysql" => ">= 2.0.0" }))
324+
allow(data_store).to receive(:exists_dir?)
325+
.with(org_prefix + %w{cookbooks mysql})
326+
.and_return(true)
327+
allow(data_store).to receive(:list)
328+
.with(org_prefix + %w{cookbooks mysql})
329+
.and_return(["1.0.0", "2.0.0", "3.0.0"])
330+
allow(data_store).to receive(:get)
331+
.with(org_prefix + ["cookbooks", "mysql", "3.0.0"], request)
332+
.and_return(cookbook_json("mysql", "3.0.0"))
333+
allow(data_store).to receive(:get)
334+
.with(org_prefix + ["cookbooks", "mysql", "2.0.0"], request)
335+
.and_return(cookbook_json("mysql", "2.0.0"))
336+
337+
result, _cache = endpoint.depsolve(request, %w{apache nginx}, desired, {})
338+
expect(result["apache"]).to eq(["1.0.0"])
339+
expect(result["nginx"]).to eq(["1.0.0"])
340+
expect(result["mysql"]).to eq(["3.0.0"])
341+
end
342+
343+
it "returns nil when dependency constraints from two cookbooks conflict" do
344+
desired = {
345+
"apache" => ["1.0.0"],
346+
"nginx" => ["1.0.0"],
347+
}
348+
allow(data_store).to receive(:get)
349+
.with(org_prefix + ["cookbooks", "apache", "1.0.0"], request)
350+
.and_return(cookbook_json("apache", "1.0.0", { "mysql" => ">= 2.0.0" }))
351+
allow(data_store).to receive(:get)
352+
.with(org_prefix + ["cookbooks", "nginx", "1.0.0"], request)
353+
.and_return(cookbook_json("nginx", "1.0.0", { "mysql" => "< 2.0.0" }))
354+
allow(data_store).to receive(:exists_dir?)
355+
.with(org_prefix + %w{cookbooks mysql})
356+
.and_return(true)
357+
allow(data_store).to receive(:list)
358+
.with(org_prefix + %w{cookbooks mysql})
359+
.and_return(["1.0.0", "2.0.0"])
360+
361+
result, _cache = endpoint.depsolve(request, %w{apache nginx}, desired, {})
362+
expect(result).to be_nil
363+
end
364+
365+
it "resolves a deep dependency chain" do
366+
desired = { "app" => ["1.0.0"] }
367+
allow(data_store).to receive(:get)
368+
.with(org_prefix + ["cookbooks", "app", "1.0.0"], request)
369+
.and_return(cookbook_json("app", "1.0.0", { "framework" => ">= 1.0.0" }))
370+
allow(data_store).to receive(:exists_dir?)
371+
.with(org_prefix + %w{cookbooks framework})
372+
.and_return(true)
373+
allow(data_store).to receive(:list)
374+
.with(org_prefix + %w{cookbooks framework})
375+
.and_return(["1.0.0"])
376+
allow(data_store).to receive(:get)
377+
.with(org_prefix + ["cookbooks", "framework", "1.0.0"], request)
378+
.and_return(cookbook_json("framework", "1.0.0", { "lib" => ">= 1.0.0" }))
379+
allow(data_store).to receive(:exists_dir?)
380+
.with(org_prefix + %w{cookbooks lib})
381+
.and_return(true)
382+
allow(data_store).to receive(:list)
383+
.with(org_prefix + %w{cookbooks lib})
384+
.and_return(["1.0.0", "2.0.0"])
385+
allow(data_store).to receive(:get)
386+
.with(org_prefix + ["cookbooks", "lib", "2.0.0"], request)
387+
.and_return(cookbook_json("lib", "2.0.0"))
388+
allow(data_store).to receive(:get)
389+
.with(org_prefix + ["cookbooks", "lib", "1.0.0"], request)
390+
.and_return(cookbook_json("lib", "1.0.0"))
391+
392+
result, cache = endpoint.depsolve(request, ["app"], desired, {})
393+
expect(result["app"]).to eq(["1.0.0"])
394+
expect(result["framework"]).to eq(["1.0.0"])
395+
expect(result["lib"]).to eq(["2.0.0"])
396+
expect(cache.keys).to contain_exactly("app", "framework", "lib")
397+
end
398+
end
399+
end
61400
end

0 commit comments

Comments
 (0)