Merge branch 'master' into 3373-improve-gatk3-snv-pipeline
[arvados.git] / services / api / test / functional / arvados / v1 / job_reuse_controller_test.rb
1 require 'test_helper'
2 require 'helpers/git_test_helper'
3
4 class Arvados::V1::JobReuseControllerTest < ActionController::TestCase
5   fixtures :repositories, :users, :jobs, :links, :collections
6
7   # See git_setup.rb for the commit log for test.git.tar
8   include GitTestHelper
9
10   setup do
11     @controller = Arvados::V1::JobsController.new
12     authorize_with :active
13   end
14
15   test "reuse job with no_reuse=false" do
16     post :create, job: {
17       no_reuse: false,
18       script: "hash",
19       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
20       repository: "foo",
21       script_parameters: {
22         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
23         an_integer: '1'
24       }
25     }
26     assert_response :success
27     assert_not_nil assigns(:object)
28     new_job = JSON.parse(@response.body)
29     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
30     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
31   end
32
33   test "reuse job with find_or_create=true" do
34     post :create, {
35       job: {
36         script: "hash",
37         script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
38         repository: "foo",
39         script_parameters: {
40           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
41           an_integer: '1'
42         }
43       },
44       find_or_create: true
45     }
46     assert_response :success
47     assert_not_nil assigns(:object)
48     new_job = JSON.parse(@response.body)
49     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
50     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
51   end
52
53   test "reuse job with symbolic script_version" do
54     post :create, {
55       job: {
56         script: "hash",
57         script_version: "tag1",
58         repository: "foo",
59         script_parameters: {
60           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
61           an_integer: '1'
62         }
63       },
64       find_or_create: true
65     }
66     assert_response :success
67     assert_not_nil assigns(:object)
68     new_job = JSON.parse(@response.body)
69     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
70     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
71   end
72
73   test "do not reuse job because no_reuse=true" do
74     post :create, {
75       job: {
76         no_reuse: true,
77         script: "hash",
78         script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
79         repository: "foo",
80         script_parameters: {
81           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
82           an_integer: '1'
83         }
84       }
85     }
86     assert_response :success
87     assert_not_nil assigns(:object)
88     new_job = JSON.parse(@response.body)
89     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
90     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
91   end
92
93   test "do not reuse job because find_or_create=false" do
94     post :create, {
95       job: {
96         script: "hash",
97         script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
98         repository: "foo",
99         script_parameters: {
100           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
101           an_integer: '1'
102         }
103       },
104       find_or_create: false
105     }
106     assert_response :success
107     assert_not_nil assigns(:object)
108     new_job = JSON.parse(@response.body)
109     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
110     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
111   end
112
113   test "do not reuse job because output is not readable by user" do
114     authorize_with :job_reader
115     post :create, {
116       job: {
117         script: "hash",
118         script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
119         repository: "foo",
120         script_parameters: {
121           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
122           an_integer: '1'
123         }
124       },
125       find_or_create: true
126     }
127     assert_response :success
128     assert_not_nil assigns(:object)
129     new_job = JSON.parse(@response.body)
130     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
131     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
132   end
133
134   test "test_cannot_reuse_job_no_output" do
135     post :create, job: {
136       no_reuse: false,
137       script: "hash",
138       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
139       repository: "foo",
140       script_parameters: {
141         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
142         an_integer: '2'
143       }
144     }
145     assert_response :success
146     assert_not_nil assigns(:object)
147     new_job = JSON.parse(@response.body)
148     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykppp', new_job['uuid']
149   end
150
151   test "test_reuse_job_range" do
152     post :create, job: {
153       no_reuse: false,
154       script: "hash",
155       minimum_script_version: "tag1",
156       script_version: "master",
157       repository: "foo",
158       script_parameters: {
159         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
160         an_integer: '1'
161       }
162     }
163     assert_response :success
164     assert_not_nil assigns(:object)
165     new_job = JSON.parse(@response.body)
166     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
167     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
168   end
169
170   test "cannot_reuse_job_no_minimum_given_so_must_use_specified_commit" do
171     post :create, job: {
172       no_reuse: false,
173       script: "hash",
174       script_version: "master",
175       repository: "foo",
176       script_parameters: {
177         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
178         an_integer: '1'
179       }
180     }
181     assert_response :success
182     assert_not_nil assigns(:object)
183     new_job = JSON.parse(@response.body)
184     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
185     assert_equal '077ba2ad3ea24a929091a9e6ce545c93199b8e57', new_job['script_version']
186   end
187
188   test "test_cannot_reuse_job_different_input" do
189     post :create, job: {
190       no_reuse: false,
191       script: "hash",
192       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
193       repository: "foo",
194       script_parameters: {
195         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
196         an_integer: '2'
197       }
198     }
199     assert_response :success
200     assert_not_nil assigns(:object)
201     new_job = JSON.parse(@response.body)
202     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
203     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
204   end
205
206   test "test_cannot_reuse_job_different_version" do
207     post :create, job: {
208       no_reuse: false,
209       script: "hash",
210       script_version: "master",
211       repository: "foo",
212       script_parameters: {
213         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
214         an_integer: '2'
215       }
216     }
217     assert_response :success
218     assert_not_nil assigns(:object)
219     new_job = JSON.parse(@response.body)
220     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
221     assert_equal '077ba2ad3ea24a929091a9e6ce545c93199b8e57', new_job['script_version']
222   end
223
224   test "test_can_reuse_job_submitted_nondeterministic" do
225     post :create, job: {
226       no_reuse: false,
227       script: "hash",
228       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
229       repository: "foo",
230       script_parameters: {
231         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
232         an_integer: '1'
233       },
234       nondeterministic: true
235     }
236     assert_response :success
237     assert_not_nil assigns(:object)
238     new_job = JSON.parse(@response.body)
239     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
240     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
241   end
242
243   test "test_cannot_reuse_job_past_nondeterministic" do
244     post :create, job: {
245       no_reuse: false,
246       script: "hash2",
247       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
248       repository: "foo",
249       script_parameters: {
250         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
251         an_integer: '1'
252       }
253     }
254     assert_response :success
255     assert_not_nil assigns(:object)
256     new_job = JSON.parse(@response.body)
257     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykyyy', new_job['uuid']
258     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
259   end
260
261   test "test_cannot_reuse_job_no_permission" do
262     authorize_with :spectator
263     post :create, job: {
264       no_reuse: false,
265       script: "hash",
266       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
267       repository: "foo",
268       script_parameters: {
269         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
270         an_integer: '1'
271       }
272     }
273     assert_response :success
274     assert_not_nil assigns(:object)
275     new_job = JSON.parse(@response.body)
276     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
277     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
278   end
279
280   test "test_cannot_reuse_job_excluded" do
281     post :create, job: {
282       no_reuse: false,
283       script: "hash",
284       minimum_script_version: "31ce37fe365b3dc204300a3e4c396ad333ed0556",
285       script_version: "master",
286       repository: "foo",
287       exclude_script_versions: ["tag1"],
288       script_parameters: {
289         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
290         an_integer: '1'
291       }
292     }
293     assert_response :success
294     assert_not_nil assigns(:object)
295     new_job = JSON.parse(@response.body)
296     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
297     assert_equal '077ba2ad3ea24a929091a9e6ce545c93199b8e57', new_job['script_version']
298   end
299
300   test "cannot reuse job with find_or_create but excluded version" do
301     post :create, {
302       job: {
303         script: "hash",
304         script_version: "master",
305         repository: "foo",
306         script_parameters: {
307           input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
308           an_integer: '1'
309         }
310       },
311       find_or_create: true,
312       minimum_script_version: "31ce37fe365b3dc204300a3e4c396ad333ed0556",
313       exclude_script_versions: ["tag1"],
314     }
315     assert_response :success
316     assert_not_nil assigns(:object)
317     new_job = JSON.parse(@response.body)
318     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
319     assert_equal '077ba2ad3ea24a929091a9e6ce545c93199b8e57', new_job['script_version']
320   end
321
322   BASE_FILTERS = {
323     'repository' => ['=', 'foo'],
324     'script' => ['=', 'hash'],
325     'script_version' => ['in git', 'master'],
326     'docker_image_locator' => ['=', nil],
327   }
328
329   def filters_from_hash(hash)
330     hash.each_pair.map { |name, filter| [name] + filter }
331   end
332
333   test "can reuse a Job based on filters" do
334     filter_h = BASE_FILTERS.
335       merge('script_version' => ['in git', 'tag1'])
336     post(:create, {
337            job: {
338              script: "hash",
339              script_version: "master",
340              repository: "foo",
341              script_parameters: {
342                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
343                an_integer: '1'
344              }
345            },
346            filters: filters_from_hash(filter_h),
347            find_or_create: true,
348          })
349     assert_response :success
350     assert_not_nil assigns(:object)
351     new_job = JSON.parse(@response.body)
352     assert_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
353     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
354   end
355
356   test "can not reuse a Job based on filters" do
357     filter_a = filters_from_hash(BASE_FILTERS.reject { |k| k == 'script_version' })
358     filter_a += [["script_version", "in git",
359                   "31ce37fe365b3dc204300a3e4c396ad333ed0556"],
360                  ["script_version", "not in git", ["tag1"]]]
361     post(:create, {
362            job: {
363              script: "hash",
364              script_version: "master",
365              repository: "foo",
366              script_parameters: {
367                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
368                an_integer: '1'
369              }
370            },
371            filters: filter_a,
372            find_or_create: true,
373          })
374     assert_response :success
375     assert_not_nil assigns(:object)
376     new_job = JSON.parse(@response.body)
377     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
378     assert_equal '077ba2ad3ea24a929091a9e6ce545c93199b8e57', new_job['script_version']
379   end
380
381   test "can not reuse a Job based on arbitrary filters" do
382     filter_h = BASE_FILTERS.
383       merge("created_at" => ["<", "2010-01-01T00:00:00Z"])
384     post(:create, {
385            job: {
386              script: "hash",
387              script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
388              repository: "foo",
389              script_parameters: {
390                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
391                an_integer: '1'
392              }
393            },
394            filters: filters_from_hash(filter_h),
395            find_or_create: true,
396          })
397     assert_response :success
398     assert_not_nil assigns(:object)
399     new_job = JSON.parse(@response.body)
400     assert_not_equal 'zzzzz-8i9sb-cjs4pklxxjykqqq', new_job['uuid']
401     assert_equal '4fe459abe02d9b365932b8f5dc419439ab4e2577', new_job['script_version']
402   end
403
404   test "can reuse a Job with a Docker image" do
405     post(:create, {
406            job: {
407              script: "hash",
408              script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
409              repository: "foo",
410              script_parameters: {
411                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
412                an_integer: '1'
413              },
414              runtime_constraints: {
415                docker_image: 'arvados/apitestfixture',
416              }
417            },
418            find_or_create: true,
419          })
420     assert_response :success
421     new_job = assigns(:object)
422     assert_not_nil new_job
423     target_job = jobs(:previous_docker_job_run)
424     [:uuid, :script_version, :docker_image_locator].each do |attr|
425       assert_equal(target_job.send(attr), new_job.send(attr))
426     end
427   end
428
429   test "can reuse a Job with a Docker image hash filter" do
430     filter_h = BASE_FILTERS.
431       merge("script_version" =>
432               ["=", "4fe459abe02d9b365932b8f5dc419439ab4e2577"],
433             "docker_image_locator" =>
434               ["in docker", links(:docker_image_collection_hash).name])
435     post(:create, {
436            job: {
437              script: "hash",
438              script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
439              repository: "foo",
440              script_parameters: {
441                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
442                an_integer: '1'
443              },
444            },
445            filters: filters_from_hash(filter_h),
446            find_or_create: true,
447          })
448     assert_response :success
449     new_job = assigns(:object)
450     assert_not_nil new_job
451     target_job = jobs(:previous_docker_job_run)
452     [:uuid, :script_version, :docker_image_locator].each do |attr|
453       assert_equal(target_job.send(attr), new_job.send(attr))
454     end
455   end
456
457   test "reuse Job with Docker image repo+tag" do
458     filter_h = BASE_FILTERS.
459       merge("script_version" =>
460               ["=", "4fe459abe02d9b365932b8f5dc419439ab4e2577"],
461             "docker_image_locator" =>
462               ["in docker", links(:docker_image_collection_tag2).name])
463     post(:create, {
464            job: {
465              script: "hash",
466              script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
467              repository: "foo",
468              script_parameters: {
469                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
470                an_integer: '1'
471              },
472            },
473            filters: filters_from_hash(filter_h),
474            find_or_create: true,
475          })
476     assert_response :success
477     new_job = assigns(:object)
478     assert_not_nil new_job
479     target_job = jobs(:previous_docker_job_run)
480     [:uuid, :script_version, :docker_image_locator].each do |attr|
481       assert_equal(target_job.send(attr), new_job.send(attr))
482     end
483   end
484
485   test "new job with unknown Docker image filter" do
486     filter_h = BASE_FILTERS.
487       merge("docker_image_locator" => ["in docker", "_nonesuchname_"])
488     post(:create, {
489            job: {
490              script: "hash",
491              script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
492              repository: "foo",
493              script_parameters: {
494                input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
495                an_integer: '1'
496              },
497            },
498            filters: filters_from_hash(filter_h),
499            find_or_create: true,
500          })
501     assert_response :success
502     new_job = assigns(:object)
503     assert_not_nil new_job
504     assert_not_equal(jobs(:previous_docker_job_run).uuid, new_job.uuid)
505   end
506
507   ["repository", "script"].each do |skip_key|
508     test "missing #{skip_key} filter raises an error" do
509       filter_a = filters_from_hash(BASE_FILTERS.reject { |k| k == skip_key })
510       post(:create, {
511              job: {
512                script: "hash",
513                script_version: "master",
514                repository: "foo",
515                script_parameters: {
516                  input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
517                  an_integer: '1'
518                }
519              },
520              filters: filter_a,
521              find_or_create: true,
522            })
523       assert_includes(405..599, @response.code.to_i,
524                       "bad status code with missing #{skip_key} filter")
525     end
526   end
527
528   test "find Job with script version range" do
529     get :index, filters: [["repository", "=", "foo"],
530                           ["script", "=", "hash"],
531                           ["script_version", "in git", "tag1"]]
532     assert_response :success
533     assert_not_nil assigns(:objects)
534     assert_includes(assigns(:objects).map { |job| job.uuid },
535                     jobs(:previous_job_run).uuid)
536   end
537
538   test "find Job with script version range exclusions" do
539     get :index, filters: [["repository", "=", "foo"],
540                           ["script", "=", "hash"],
541                           ["script_version", "not in git", "tag1"]]
542     assert_response :success
543     assert_not_nil assigns(:objects)
544     refute_includes(assigns(:objects).map { |job| job.uuid },
545                     jobs(:previous_job_run).uuid)
546   end
547
548   test "find Job with Docker image range" do
549     get :index, filters: [["docker_image_locator", "in docker",
550                            "arvados/apitestfixture"]]
551     assert_response :success
552     assert_not_nil assigns(:objects)
553     assert_includes(assigns(:objects).map { |job| job.uuid },
554                     jobs(:previous_docker_job_run).uuid)
555     refute_includes(assigns(:objects).map { |job| job.uuid },
556                     jobs(:previous_job_run).uuid)
557   end
558
559   test "find Job with Docker image using reader tokens" do
560     authorize_with :inactive
561     get(:index, {
562           filters: [["docker_image_locator", "in docker",
563                      "arvados/apitestfixture"]],
564           reader_tokens: [api_token(:active)],
565         })
566     assert_response :success
567     assert_not_nil assigns(:objects)
568     assert_includes(assigns(:objects).map { |job| job.uuid },
569                     jobs(:previous_docker_job_run).uuid)
570     refute_includes(assigns(:objects).map { |job| job.uuid },
571                     jobs(:previous_job_run).uuid)
572   end
573
574   test "'in docker' filter accepts arrays" do
575     get :index, filters: [["docker_image_locator", "in docker",
576                            ["_nonesuchname_", "arvados/apitestfixture"]]]
577     assert_response :success
578     assert_not_nil assigns(:objects)
579     assert_includes(assigns(:objects).map { |job| job.uuid },
580                     jobs(:previous_docker_job_run).uuid)
581     refute_includes(assigns(:objects).map { |job| job.uuid },
582                     jobs(:previous_job_run).uuid)
583   end
584
585   test "'not in docker' filter accepts arrays" do
586     get :index, filters: [["docker_image_locator", "not in docker",
587                            ["_nonesuchname_", "arvados/apitestfixture"]]]
588     assert_response :success
589     assert_not_nil assigns(:objects)
590     assert_includes(assigns(:objects).map { |job| job.uuid },
591                     jobs(:previous_job_run).uuid)
592     refute_includes(assigns(:objects).map { |job| job.uuid },
593                     jobs(:previous_docker_job_run).uuid)
594   end
595
596   def create_foo_hash_job_params(params)
597     if not params.has_key?(:find_or_create)
598       params[:find_or_create] = true
599     end
600     job_attrs = params.delete(:job) || {}
601     params[:job] = {
602       script: "hash",
603       script_version: "4fe459abe02d9b365932b8f5dc419439ab4e2577",
604       repository: "foo",
605       script_parameters: {
606         input: 'fa7aeb5140e2848d39b416daeef4ffc5+45',
607         an_integer: '1',
608       },
609     }.merge(job_attrs)
610     params
611   end
612
613   def check_new_job_created_from(params)
614     start_time = Time.now
615     post(:create, create_foo_hash_job_params(params))
616     assert_response :success
617     new_job = assigns(:object)
618     assert_not_nil new_job
619     assert_operator(start_time, :<=, new_job.created_at)
620     new_job
621   end
622
623   def check_errors_from(params)
624     post(:create, create_foo_hash_job_params(params))
625     assert_includes(405..499, @response.code.to_i)
626     errors = json_response.fetch("errors", [])
627     assert(errors.any?, "no errors assigned from #{params}")
628     refute(errors.any? { |msg| msg =~ /^#<[A-Za-z]+: / },
629            "errors include raw exception")
630     errors
631   end
632
633   # 1de84a8 is on the b1 branch, after master's tip.
634   test "new job created from unsatisfiable minimum version filter" do
635     filter_h = BASE_FILTERS.merge("script_version" => ["in git", "1de84a8"])
636     check_new_job_created_from(filters: filters_from_hash(filter_h))
637   end
638
639   test "new job created from unsatisfiable minimum version parameter" do
640     check_new_job_created_from(minimum_script_version: "1de84a8")
641   end
642
643   test "new job created from unsatisfiable minimum version attribute" do
644     check_new_job_created_from(job: {minimum_script_version: "1de84a8"})
645   end
646
647   test "graceful error from nonexistent minimum version filter" do
648     filter_h = BASE_FILTERS.merge("script_version" =>
649                                   ["in git", "__nosuchbranch__"])
650     errors = check_errors_from(filters: filters_from_hash(filter_h))
651     assert(errors.any? { |msg| msg.include? "__nosuchbranch__" },
652            "bad refspec not mentioned in error message")
653   end
654
655   test "graceful error from nonexistent minimum version parameter" do
656     errors = check_errors_from(minimum_script_version: "__nosuchbranch__")
657     assert(errors.any? { |msg| msg.include? "__nosuchbranch__" },
658            "bad refspec not mentioned in error message")
659   end
660
661   test "graceful error from nonexistent minimum version attribute" do
662     errors = check_errors_from(job: {minimum_script_version: "__nosuchbranch__"})
663     assert(errors.any? { |msg| msg.include? "__nosuchbranch__" },
664            "bad refspec not mentioned in error message")
665   end
666 end