etsy-deployinator 1.0.1 → 1.1.1

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.
@@ -0,0 +1,180 @@
1
+ <!-- content -->
2
+ <section id="main" class="info stats">
3
+ <!-- heading -->
4
+ <div class="heading clearfix">
5
+ <h2>Deployments Per Day (US/Eastern)</h2>
6
+ </div>
7
+ <div class="stats-main">
8
+ <button id="scatterOrLine">Line</button>
9
+ <label><input type="checkbox" id="combined"> Combined</label>
10
+ <div id="placeholder" style="width:940px;height:300px;"></div>
11
+ <div id="overview" style="width:940px;height:120px;"></div>
12
+ <script>var data = {}</script>
13
+
14
+ <p id="choices"></p>
15
+
16
+ <div class="holder">
17
+
18
+ {{# per_day}}
19
+ <div class="stats">
20
+ <table class="stats">
21
+ <tr>
22
+ <th>Day</th>
23
+ <th>Count</th>
24
+ </tr>
25
+ {{# data}}
26
+ <tr>
27
+ <td>{{date}}</td>
28
+ <td class="count">{{count}}</td>
29
+ </tr>
30
+ {{/ data}}
31
+ </table>
32
+ <h3>{{stack}}</h3>
33
+ <script type="text/javascript" charset="utf-8">
34
+ data["{{stack}}"] = {data: {{json}}, label: "{{stack}}"}
35
+ </script>
36
+ </div>
37
+ {{/ per_day}}
38
+
39
+ </div>
40
+ </div>
41
+
42
+ </section>
43
+
44
+ <script type="text/javascript" charset="utf-8">
45
+ var x1, x2;
46
+
47
+ function getData(ignoreX) {
48
+ var d = [];
49
+ var c = { data: [], label: "combined" };
50
+
51
+ $("#choices").find("input:checked").each(function() {
52
+ if ($(this).attr("id") == "combined") { return; }
53
+
54
+ var key = $(this).attr("name");
55
+
56
+ if (key && data[key]) {
57
+ // combine all the data points if requested
58
+ if ($("#combined")[0].checked) {
59
+ for (var i=0; i<data[key].data.length; i++) {
60
+ var found = false;
61
+ var m = data[key].data[i];
62
+ for (var j=0; j<c.data.length; j++) {
63
+ var n = c.data[j];
64
+ if (n[0] == m[0]) {
65
+ n[1] += m[1];
66
+ found = true;
67
+ break;
68
+ }
69
+ }
70
+
71
+ if (! found) { c.data[i] = [m[0], m[1]]; }
72
+ }
73
+ }
74
+
75
+ d.push(data[key]);
76
+ }
77
+ });
78
+
79
+ if ($("#combined")[0].checked) { d = [c]; }
80
+
81
+ var newD = [];
82
+ for (var i=0; i<d.length; i++) {
83
+ var dAr = [];
84
+ for (var j=0; j<d[i].data.length; j++) {
85
+ n = d[i].data[j];
86
+ dAr.push(n);
87
+ }
88
+ newD[i] = { label: d[i].label, data: dAr };
89
+ }
90
+
91
+ if ($("#combined")[0].checked) { outputTable(c); }
92
+ return newD;
93
+ }
94
+
95
+ function outputTable(c) {
96
+ $("#holderCombined").remove();
97
+
98
+ var table = $("<table id='holderCombined'>")
99
+ .html("<tr><th>Date</th><th>Combined</th></tr>")
100
+ .appendTo(".stats-main");
101
+
102
+ for (var i=0; i<c.data.length; i++) {
103
+ var m = c.data[i];
104
+ var dO = new Date(m[0]);
105
+ var dateS = (dO.getYear() + 1900) + "-" + dO.getMonth() + "-" + dO.getDate();
106
+ $("<tr><td>" + dateS + "</td><td>" + m[1] + "</td></tr>")
107
+ .appendTo(table);
108
+ }
109
+ }
110
+
111
+ function options() {
112
+ return {
113
+ xaxis: { mode: "time", timeformat: "%y/%m/%d", minTickSize: [1, "day"] },
114
+ legend: { position: "nw" },
115
+ points: { show: ($("#scatterOrLine").html() == "Scatter") },
116
+ lines: { show: ($("#scatterOrLine").html() == "Line") },
117
+ selection: { mode: "x" }
118
+ }
119
+ };
120
+
121
+ function sliceD(d, msg) {
122
+ return d.slice(d.slice(d.indexOf(x1), d.indexOf(x2)));
123
+ }
124
+
125
+ function drawGraphs() {
126
+ var d = getData();
127
+
128
+ var plot = $.plot($("#placeholder"), sliceD(d, "main"), options());
129
+
130
+ var overview = $.plot($("#overview"), d, {
131
+ legend: { show: false },
132
+ selection: { mode: "x" },
133
+ lines: { show: true },
134
+ xaxis: { mode: "time", timeformat: "%y/%m/%d" }
135
+ });
136
+
137
+ $("#placeholder").unbind("plotselected");
138
+ $("#placeholder").bind("plotselected", function (event, cRanges) {
139
+ ranges = cRanges;
140
+ x1 = Math.floor(ranges.xaxis.from);
141
+ x2 = Math.floor(ranges.xaxis.to);
142
+
143
+ // do the zooming
144
+ plot = $.plot($("#placeholder"), sliceD(d, "zoom"),
145
+ $.extend(true, {}, options(), {
146
+ xaxis: { min: ranges.xaxis.from, max: ranges.xaxis.to },
147
+ yaxis: { min: ranges.yaxis.from, max: ranges.yaxis.to }
148
+ }));
149
+
150
+ // don't fire event on the overview to prevent eternal loop
151
+ overview.setSelection(ranges, true);
152
+ });
153
+
154
+ $("#overview").unbind("plotselected");
155
+ $("#overview").bind("plotselected", function (event, ranges) {
156
+ plot.setSelection(ranges);
157
+
158
+ x1 = Math.floor(ranges.xaxis.from);
159
+ x2 = Math.floor(ranges.xaxis.to);
160
+ });
161
+
162
+ if (x1 && x2) {
163
+ ranges = {xaxis: {from: x1, to: x2}};
164
+
165
+ overview.setSelection(ranges);
166
+ plot.setSelection(ranges);
167
+ }
168
+ }
169
+
170
+ $("#scatterOrLine").live("click", function () {
171
+ $(this).html(($(this).html() == "Line") ? "Scatter" : "Line");
172
+
173
+ drawGraphs();
174
+ });
175
+
176
+ $("#combined").live("click", function () {
177
+ drawGraphs();
178
+ });
179
+ </script>
180
+ <script src="/js/stats_load.js"></script>
@@ -1,3 +1,3 @@
1
1
  module Deployinator
2
- VERSION = "1.0.1"
2
+ VERSION = "1.1.1"
3
3
  end
@@ -17,6 +17,42 @@ module Deployinator::Views
17
17
  @params[:show_counts] == "true"
18
18
  end
19
19
 
20
+ def next_page_params
21
+ params = [
22
+ {
23
+ :name => "page",
24
+ :value => next_page
25
+ }
26
+ ]
27
+
28
+ unless @params[:stack].nil?
29
+ params << {
30
+ :name => "stack",
31
+ :value => @params[:stack]
32
+ }
33
+ end
34
+
35
+ return params
36
+ end
37
+
38
+ def prev_page_params
39
+ params = [
40
+ {
41
+ :name => "page",
42
+ :value => prev_page
43
+ }
44
+ ]
45
+
46
+ unless @params[:stack].nil?
47
+ params << {
48
+ :name => "stack",
49
+ :value => @params[:stack]
50
+ }
51
+ end
52
+
53
+ return params
54
+ end
55
+
20
56
  def prev_page
21
57
  return unless @params && @params[:page]
22
58
  page = @params[:page].to_i
@@ -0,0 +1,15 @@
1
+ module Deployinator::Views
2
+ class Maintenance < Layout
3
+ self.template_file = "#{File.dirname(__FILE__)}/../templates/maintenance.mustache"
4
+ def additional_header_html
5
+ <<-EOS
6
+ #{super}
7
+ <link rel="stylesheet" href="/css/maintenance.css" type="text/css" media="screen">
8
+ EOS
9
+ end
10
+
11
+ def maintenance_contact
12
+ Deployinator.maintenance_contact
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,96 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module Deployinator::Views
5
+ class Stats < Layout
6
+
7
+ self.template_file = "#{File.dirname(__FILE__)}/../templates/stats.mustache"
8
+
9
+ @@ignored_stacks = Deployinator.stats_ignored_stacks || []
10
+
11
+ def deploys
12
+ @deploys ||= begin
13
+ log_to_hash({
14
+ :no_global => false,
15
+ :stack => Deployinator.stats_included_stacks,
16
+ :env => "production|search|prod",
17
+ :extragrep => Deployinator.stats_extra_grep,
18
+ :no_limit => true,
19
+ :limit => 10000
20
+ })
21
+ end
22
+ # note that the stack param will help but will send bring back extra lines that matched
23
+ end
24
+
25
+ def timings
26
+ deploys
27
+ end
28
+
29
+ def inject_renamed_stacks(renamed_stacks)
30
+ return if nil == renamed_stacks
31
+
32
+ renamed_stacks.each do |ops|
33
+ previous_stack = ops[:previous_stack]
34
+ new_stack_name = ops[:new_name]
35
+
36
+ renamed_stack_data = log_to_hash(previous_stack)
37
+
38
+ renamed_stack_data.each do |data|
39
+ data[:stack] = new_stack_name
40
+ deploys.push(data)
41
+ end
42
+ end
43
+ end
44
+
45
+ def per_day
46
+ inject_renamed_stacks(Deployinator.stats_renamed_stacks)
47
+
48
+ original_zone = ENV["TZ"]
49
+ ENV["TZ"] = "US/Eastern"
50
+
51
+ early_day = Time.now.strftime("%Y-%m-%d")
52
+ stack_days = deploys.inject({}) do |h, deploy|
53
+ if deploy[:time] && deploy[:stack]
54
+ if @@ignored_stacks.include?(deploy[:stack])
55
+ # puts "SKIPPING " + deploy[:stack]
56
+ # something breaks if you just next here so don't
57
+ else
58
+ day = Date.parse(deploy[:time].localtime.strftime("%Y-%m-%d"))
59
+ early_day = day if day.to_s < early_day.to_s
60
+ h[deploy[:stack]] ||= {}
61
+ h[deploy[:stack]][day] ||= 0
62
+ h[deploy[:stack]][day] += 1
63
+ end
64
+ end
65
+ h
66
+ end
67
+
68
+ # fill in zero days
69
+ day_seconds = 24 * 60 * 60
70
+ (0..((Time.now - Time.parse(early_day.to_s)) / day_seconds).to_i).each do |days_ago|
71
+ stack_days.keys.each do |stack|
72
+
73
+ next if @@ignored_stacks.include?(stack)
74
+ day = Date.parse((Time.now - (day_seconds * days_ago)).strftime("%Y-%m-%d"))
75
+ stack_days[stack][day] ||= 0
76
+ end
77
+ end
78
+
79
+ n = stack_days.keys.map do |stack|
80
+ next if @@ignored_stacks.include?(stack)
81
+
82
+ data = []
83
+ json = []
84
+ stack_days[stack].sort.reverse.each do |d,c|
85
+ data << {:date => d, :count => c}
86
+ json << [d.strftime("%s").to_i * 1000, c]
87
+ end
88
+ {:stack => stack, :data => data, :json => json.to_json}
89
+ end
90
+
91
+ ENV["TZ"] = original_zone
92
+
93
+ n
94
+ end
95
+ end
96
+ end
data/lib/deployinator.rb CHANGED
@@ -54,10 +54,27 @@ module Deployinator
54
54
  attr_accessor :admin_groups
55
55
  @admin_groups = []
56
56
 
57
+ attr_accessor :maintenance_mode
58
+
59
+ attr_accessor :maintenance_contact
60
+
57
61
  # the controller class. defaults to Deployinator::Controller
58
62
  # if you override this it should be a subclass of Deployinator::Controller
59
63
  attr_accessor :deploy_controller
60
64
 
65
+ # include stacks in /stats
66
+ attr_accessor :stats_included_stacks
67
+
68
+ # exclude stacks in /stats, even if present in stats_included_stacks
69
+ attr_accessor :stats_ignored_stacks
70
+
71
+ # filter log entries in /stats
72
+ attr_accessor :stats_extra_grep
73
+
74
+ # list of configurations for grouping historical / renamed stacks in /stats
75
+ attr_accessor :stats_renamed_stacks
76
+ @@stats_renamed_stacks = []
77
+
61
78
  def initialize
62
79
  @stack_plugins = {}
63
80
  @global_plugins = []
@@ -101,12 +118,14 @@ end
101
118
  Deployinator.root_dir = Dir.pwd
102
119
  Deployinator.app_context = {}
103
120
  Deployinator.admin_groups = []
121
+ Deployinator.maintenance_mode = false
122
+ Deployinator.maintenance_contact = "admin@deployinator.example.com"
104
123
  Deployinator.global_plugins = []
105
124
  Deployinator.log_file = Deployinator.root(["log", "development.log"])
106
125
  Deployinator.log_path = Deployinator.root(["log", "deployinator.log"])
107
126
  Deployinator.timing_log_path = Deployinator.root(["log", "deployinator-timing.log"])
108
127
  Deployinator.git_sha_length = "10"
109
- Deployinator.default_user = `whoami`
128
+ Deployinator.default_user = `/usr/bin/whoami`
110
129
  Deployinator.stack_tailer_port = 7778
111
130
  Deployinator.github_host = 'github.com'
112
131
  Deployinator.app_context['context'] = 'dev'
@@ -0,0 +1,72 @@
1
+ require 'deployinator'
2
+ require 'deployinator/helpers'
3
+ require 'deployinator/helpers/concurrency'
4
+ require 'test/unit'
5
+ require 'mocha/setup'
6
+
7
+ Celluloid.logger = nil
8
+ class ConcurrencyTest < Test::Unit::TestCase
9
+ # Celluloid recommends doing this before each test
10
+ # https://github.com/celluloid/celluloid/wiki/Gotchas#testing
11
+ include Deployinator::Helpers::ConcurrencyHelpers
12
+ def setup
13
+ Celluloid.boot
14
+ @@futures = {}
15
+ end
16
+
17
+ def teardown
18
+ Celluloid.shutdown
19
+ end
20
+
21
+ def test_spawn_off_concurrent_thread
22
+ run_parallel(:test) do
23
+ "Running inside thread"
24
+ end
25
+ assert_equal(get_value(:test), "Running inside thread")
26
+ end
27
+
28
+ def test_future_name_type
29
+ assert_raise NoMethodError do
30
+ run_parallel({:test => 'going crazy'}) do
31
+ "Running inside thread"
32
+ end
33
+ end
34
+ end
35
+
36
+ def test_fibers_with_same_reference
37
+ run_parallel(:test) do
38
+ "Running inside thread"
39
+ end
40
+ assert_raise Deployinator::Helpers::ConcurrencyHelpers::DuplicateReferenceError do
41
+ run_parallel(:test) do
42
+ "Running inside thread"
43
+ end
44
+ end
45
+ end
46
+
47
+ def test_get_non_symbol_value
48
+ run_parallel(:test) do
49
+ "Running inside thread"
50
+ end
51
+ assert_raise NoMethodError do
52
+ get_value({:test => 'going crazy - jpaul'})
53
+ end
54
+ end
55
+
56
+ def test_reference_taken
57
+ assert_equal(reference_taken?(:test), false)
58
+ @@futures[:test] = 'a value'
59
+ assert_equal(reference_taken?(:test), true)
60
+ end
61
+
62
+ def test_multiple_futures_return
63
+ run_parallel(:test1) do
64
+ "future1"
65
+ end
66
+ run_parallel(:test2) do
67
+ "future2"
68
+ end
69
+ assert_equal(get_values(:test1, :test2), {:test1 => "future1", :test2 => "future2"})
70
+ end
71
+
72
+ end
@@ -0,0 +1,70 @@
1
+ require 'deployinator'
2
+ require 'deployinator/helpers'
3
+ require 'deployinator/helpers/git'
4
+
5
+ include Deployinator::Helpers::GitHelpers
6
+
7
+ class HelpersTest < Test::Unit::TestCase
8
+
9
+ def test_git_url_https
10
+ GitHelpers.expects(:which_github_host).returns("www.testmagic.com")
11
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
12
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
13
+ assert_equal('https://www.testmagic.com/construction/drills.git', GitHelpers.git_url(:stack, 'https', false))
14
+ end
15
+
16
+ def test_git_url_default
17
+ GitHelpers.expects(:which_github_host).returns("www.testmagic.com")
18
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
19
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
20
+ assert_equal('git://www.testmagic.com/construction/drills', GitHelpers.git_url(:stack))
21
+ end
22
+
23
+ def test_git_url_read_write
24
+ GitHelpers.expects(:which_github_host).returns("www.testmagic.com")
25
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
26
+ GitHelpers.expects(:git_info_for_stack).returns({:stack => {:repository => 'drills', :user => 'construction'}})
27
+ assert_equal('git@www.testmagic.com:construction/drills', GitHelpers.git_url(:stack, "git", true))
28
+ end
29
+
30
+ def test_git_freshen_or_clone_https_passthrough
31
+ GitHelpers.expects(:git_checkout_path).returns("/dev/null")
32
+ GitHelpers.expects(:is_git_repo).with("/dev/null", "extra-ssh").returns(:missing)
33
+ GitHelpers.expects(:log_and_stream).returns(nil)
34
+ GitHelpers.expects(:git_url).with(:stack, "https", false).returns("https://www.testmagic.com/construction/drills.git")
35
+ GitHelpers.expects(:git_clone).with(:stack, "https://www.testmagic.com/construction/drills.git", "extra-ssh", "/dev/null", "merge99")
36
+ GitHelpers.git_freshen_or_clone(:stack, "extra-ssh", "/dev/null", "merge99", false, "https")
37
+ end
38
+
39
+ def test_git_freshen_or_clone_git_update
40
+ GitHelpers.expects(:git_checkout_path).returns("/dev/null")
41
+ GitHelpers.expects(:is_git_repo).with("/dev/null", "extra-ssh").returns(:true)
42
+ GitHelpers.expects(:log_and_stream).returns(nil)
43
+ GitHelpers.expects(:git_freshen_clone).with(:stack, "extra-ssh", "/dev/null", "merge99", false)
44
+ GitHelpers.git_freshen_or_clone(:stack, "extra-ssh", "/dev/null", "merge99", false, "https")
45
+ end
46
+
47
+ def test_git_freshen_or_clone_git_update_force
48
+ GitHelpers.expects(:git_checkout_path).returns("/dev/null")
49
+ GitHelpers.expects(:is_git_repo).with("/dev/null", "extra-ssh").returns(:true)
50
+ GitHelpers.expects(:log_and_stream).returns(nil)
51
+ GitHelpers.expects(:git_freshen_clone).with(:stack, "extra-ssh", "/dev/null", "merge99", true)
52
+ GitHelpers.git_freshen_or_clone(:stack, "extra-ssh", "/dev/null", "merge99", false, "https", true)
53
+ end
54
+ def test_git_freshen_or_clone_git_bad_repo
55
+ GitHelpers.expects(:git_checkout_path).returns("/dev/null")
56
+ GitHelpers.expects(:is_git_repo).with("/dev/null", "extra-ssh").returns(:false)
57
+ GitHelpers.expects(:log_and_stream).returns(nil)
58
+ GitHelpers.git_freshen_or_clone(:stack, "extra-ssh", "/dev/null", "merge99", false, "https")
59
+ end
60
+
61
+ def test_git_head_rev_should_cache_results
62
+ FileUtils.rm_f('/tmp/rev_head_cache_some_stack')
63
+
64
+ head_rev_sha = 'ba83f60523008e48950f77bd0d3a773f9cb2805c'
65
+ GitHelpers.expects(:get_git_head_rev).with('some_stack', 'master').returns(head_rev_sha).once
66
+ assert_equal(head_rev_sha, GitHelpers.git_head_rev('some_stack'))
67
+ # Calling it a second time should just use the cached result on disk
68
+ assert_equal(head_rev_sha, GitHelpers.git_head_rev('some_stack'))
69
+ end
70
+ end
@@ -1,6 +1,9 @@
1
+ # encoding: utf-8
2
+
1
3
  require 'deployinator'
2
4
  require 'deployinator/helpers'
3
5
  require 'deployinator/helpers/dsh'
6
+ require 'tempfile'
4
7
 
5
8
  class HelpersTest < Test::Unit::TestCase
6
9
  include Deployinator::Helpers,
@@ -12,6 +15,7 @@ class HelpersTest < Test::Unit::TestCase
12
15
  @issue2 = "DEF-456"
13
16
  @issue1_linked = "#{@jurl % ([@issue1] * 2)}"
14
17
  @issue2_linked = "#{@jurl % ([@issue2] * 2)}"
18
+ @utf8_canary = 'Iñtërnâtiônàlizætiøn'
15
19
  Deployinator.default_user = "testuser"
16
20
  Deployinator.deploy_host = "deploytest.vm.ny4dev.etsy.com"
17
21
  Deployinator.issue_tracker = proc do |issue|
@@ -61,9 +65,9 @@ class HelpersTest < Test::Unit::TestCase
61
65
 
62
66
  def test_with_timeout_success
63
67
  res = with_timeout 3 do
64
- `sleep 2 && echo -n "foo"`
68
+ `sleep 2 && echo "foo"`
65
69
  end
66
- assert_equal "foo", res
70
+ assert_equal "foo\n", res
67
71
  end
68
72
 
69
73
  def test_strip_to_nil
@@ -74,4 +78,59 @@ class HelpersTest < Test::Unit::TestCase
74
78
  assert_equal('host01,host02', strip_ws_to_nil(' host01 , host02 '))
75
79
  assert_equal('host01', strip_ws_to_nil('host01'))
76
80
  end
81
+
82
+ def test_get_from_cache
83
+ Tempfile.open('cache_file', encoding: 'UTF-8') do |tf|
84
+ tf.write(@utf8_canary)
85
+ tf.flush
86
+
87
+ cached_content = get_from_cache(tf.path)
88
+ assert_equal(@utf8_canary, cached_content)
89
+ assert_equal(Encoding.find('UTF-8'), cached_content.encoding)
90
+ end
91
+ end
92
+
93
+ def test_get_from_cache_when_file_does_not_exist
94
+ assert_equal(false, get_from_cache('/does/not/exist'))
95
+ assert_equal(false, get_from_cache('/does/not/exist', -1))
96
+ assert_equal(false, get_from_cache('/does/not/exist', 10))
97
+ end
98
+
99
+ def test_get_from_cache_when_file_is_old
100
+ Tempfile.open('cache_file') do |tf|
101
+ File.stubs(:mtime).with(tf.path).returns(Time.now - 10)
102
+
103
+ assert_equal(false, get_from_cache(tf.path))
104
+ assert_not_equal(false, get_from_cache(tf.path, 30))
105
+ assert_not_equal(false, get_from_cache(tf.path, -1))
106
+ end
107
+ end
108
+
109
+ def test_write_to_cache
110
+ Tempfile.open('cache_file') do |tf|
111
+ write_to_cache(tf.path, @utf8_canary)
112
+
113
+ cached_content = get_from_cache(tf.path)
114
+ assert_equal(@utf8_canary, cached_content)
115
+ assert_equal(Encoding.find('UTF-8'), cached_content.encoding)
116
+ end
117
+ end
118
+
119
+ def test_is_admin_groups_nil
120
+ assert_false(is_admin?)
121
+ end
122
+
123
+ def test_is_admin_groups_true
124
+ assert_true(is_admin?(["foo", "bar"], ["bar"]))
125
+ end
126
+
127
+ def test_is_admin_groups_false
128
+ assert_false(is_admin?(["foo", "bar"], ["bla"]))
129
+ end
130
+
131
+ def test_is_admin_groups_true_with_fallback
132
+ @groups = ["foo", "bar"]
133
+ Deployinator.admin_groups = ["bar"]
134
+ assert_true(is_admin?(["foo", "bar"], ["bar"]))
135
+ end
77
136
  end