Merge branch 'master' into timestamps_in_twitter_agent

Andrew Cantino 9 gadi atpakaļ
vecāks
revīzija
70eb0b154c
147 mainītis faili ar 2010 papildinājumiem un 358 dzēšanām
  1. 6 0
      .env.example
  2. 12 0
      CHANGES.md
  3. 2 2
      Gemfile
  4. 15 9
      Gemfile.lock
  5. 1 0
      app/assets/javascripts/diagram.js.coffee
  6. 2 2
      app/concerns/agent_controller_concern.rb
  7. 21 5
      app/concerns/long_runnable.rb
  8. 1 1
      app/concerns/sortable_events.rb
  9. 2 7
      app/concerns/web_request_concern.rb
  10. 7 5
      app/helpers/dot_helper.rb
  11. 128 0
      app/models/agents/beeper_agent.rb
  12. 1 1
      app/models/agents/commander_agent.rb
  13. 100 28
      app/models/agents/data_output_agent.rb
  14. 73 0
      app/models/agents/delay_agent.rb
  15. 1 0
      app/models/agents/event_formatting_agent.rb
  16. 1 1
      app/models/agents/ftpsite_agent.rb
  17. 67 0
      app/models/agents/gap_detector_agent.rb
  18. 1 1
      app/models/agents/peak_detector_agent.rb
  19. 26 19
      app/models/agents/rss_agent.rb
  20. 0 4
      app/models/agents/scheduler_agent.rb
  21. 64 24
      app/models/agents/shell_command_agent.rb
  22. 14 5
      app/models/agents/slack_agent.rb
  23. 23 4
      app/models/agents/trigger_agent.rb
  24. 29 7
      app/models/agents/tumblr_publish_agent.rb
  25. 105 0
      app/models/agents/twitter_search_agent.rb
  26. 7 7
      app/models/agents/twitter_stream_agent.rb
  27. 13 13
      app/models/agents/weather_agent.rb
  28. 8 3
      app/models/agents/webhook_agent.rb
  29. 12 4
      app/models/agents/website_agent.rb
  30. 1 2
      app/views/agents/_form.html.erb
  31. 1 1
      app/views/agents/_table.html.erb
  32. 1 1
      app/views/agents/show.html.erb
  33. 1 1
      app/views/diagrams/show.html.erb
  34. 1 1
      app/views/events/index.html.erb
  35. 1 1
      config/initializers/delayed_job.rb
  36. 10 1
      config/smtp.yml
  37. 1 1
      doc/heroku/install.md
  38. 1 1
      lib/agent_runner.rb
  39. 1 1
      lib/huginn_scheduler.rb
  40. 3 0
      lib/json_with_indifferent_access.rb
  41. 6 1
      lib/tasks/production.rake
  42. 12 0
      lib/utils.rb
  43. 1 1
      spec/concerns/dry_runnable_spec.rb
  44. 1 1
      spec/concerns/form_configurable_spec.rb
  45. 1 1
      spec/concerns/inheritance_tracking_spec.rb
  46. 1 1
      spec/concerns/liquid_droppable_spec.rb
  47. 1 1
      spec/concerns/liquid_interpolatable_spec.rb
  48. 9 1
      spec/concerns/long_runnable_spec.rb
  49. 3 3
      spec/concerns/sortable_events_spec.rb
  50. 1 1
      spec/controllers/agents_controller_spec.rb
  51. 1 1
      spec/controllers/concerns/sortable_table_spec.rb
  52. 1 1
      spec/controllers/events_controller_spec.rb
  53. 1 1
      spec/controllers/jobs_controller_spec.rb
  54. 1 1
      spec/controllers/logs_controller_spec.rb
  55. 1 1
      spec/controllers/omniauth_callbacks_controller_spec.rb
  56. 1 1
      spec/controllers/scenario_imports_controller_spec.rb
  57. 1 1
      spec/controllers/scenarios_controller_spec.rb
  58. 1 1
      spec/controllers/services_controller_spec.rb
  59. 1 1
      spec/controllers/user_credentials_controller_spec.rb
  60. 1 1
      spec/controllers/web_requests_controller_spec.rb
  61. 131 0
      spec/data_fixtures/cdata_rss.atom
  62. 1 0
      spec/data_fixtures/search_tweets.json
  63. 17 0
      spec/data_fixtures/urlTest.html
  64. 21 0
      spec/fixtures/agents.yml
  65. 1 1
      spec/helpers/application_helper_spec.rb
  66. 2 2
      spec/helpers/dot_helper_spec.rb
  67. 1 1
      spec/helpers/jobs_helper_spec.rb
  68. 1 1
      spec/helpers/markdown_helper_spec.rb
  69. 1 1
      spec/helpers/scenario_helper_spec.rb
  70. 1 1
      spec/lib/agent_runner_spec.rb
  71. 1 1
      spec/lib/agents_exporter_spec.rb
  72. 1 1
      spec/lib/delayed_job_worker_spec.rb
  73. 1 1
      spec/lib/huginn_scheduler_spec.rb
  74. 1 1
      spec/lib/liquid_migrator_spec.rb
  75. 3 3
      spec/lib/location_spec.rb
  76. 1 1
      spec/lib/utils_spec.rb
  77. 1 1
      spec/models/agent_log_spec.rb
  78. 3 3
      spec/models/agent_spec.rb
  79. 1 1
      spec/models/agents/adioso_agent_spec.rb
  80. 1 1
      spec/models/agents/basecamp_agent_spec.rb
  81. 145 0
      spec/models/agents/beeper_agent_spec.rb
  82. 1 1
      spec/models/agents/change_detector_agent_spec.rb
  83. 3 3
      spec/models/agents/commander_agent_spec.rb
  84. 36 3
      spec/models/agents/data_output_agent_spec.rb
  85. 1 1
      spec/models/agents/de_duplication_agent_spec.rb
  86. 127 0
      spec/models/agents/delay_agent_spec.rb
  87. 1 1
      spec/models/agents/dropbox_file_url_agent_spec.rb
  88. 1 1
      spec/models/agents/dropbox_watch_agent_spec.rb
  89. 1 1
      spec/models/agents/email_agent_spec.rb
  90. 1 1
      spec/models/agents/email_digest_agent_spec.rb
  91. 1 1
      spec/models/agents/event_formatting_agent_spec.rb
  92. 1 1
      spec/models/agents/evernote_agent_spec.rb
  93. 2 2
      spec/models/agents/ftpsite_agent_spec.rb
  94. 112 0
      spec/models/agents/gap_detector_agent_spec.rb
  95. 1 1
      spec/models/agents/google_calendar_publish_agent_spec.rb
  96. 1 1
      spec/models/agents/growl_agent_spec.rb
  97. 1 1
      spec/models/agents/hipchat_agent_spec.rb
  98. 1 1
      spec/models/agents/human_task_agent_spec.rb
  99. 1 1
      spec/models/agents/imap_folder_agent_spec.rb
  100. 1 1
      spec/models/agents/jabber_agent_spec.rb
  101. 1 1
      spec/models/agents/java_script_agent_spec.rb
  102. 1 1
      spec/models/agents/jira_agent_spec.rb
  103. 1 1
      spec/models/agents/mqtt_agent_spec.rb
  104. 1 1
      spec/models/agents/pdf_agent_spec.rb
  105. 1 1
      spec/models/agents/peak_detector_agent_spec.rb
  106. 1 1
      spec/models/agents/post_agent_spec.rb
  107. 1 1
      spec/models/agents/public_transport_agent_spec.rb
  108. 1 1
      spec/models/agents/pushbullet_agent_spec.rb
  109. 1 1
      spec/models/agents/pushover_agent_spec.rb
  110. 1 1
      spec/models/agents/rss_agent_spec.rb
  111. 1 8
      spec/models/agents/scheduler_agent_spec.rb
  112. 1 1
      spec/models/agents/sentiment_agent_spec.rb
  113. 68 10
      spec/models/agents/shell_command_agent_spec.rb
  114. 16 3
      spec/models/agents/slack_agent_spec.rb
  115. 1 1
      spec/models/agents/stubhub_agent_spec.rb
  116. 1 1
      spec/models/agents/translation_agent_spec.rb
  117. 72 15
      spec/models/agents/trigger_agent_spec.rb
  118. 79 31
      spec/models/agents/tumblr_publish_agent_spec.rb
  119. 1 1
      spec/models/agents/twilio_agent_spec.rb
  120. 1 1
      spec/models/agents/twitter_publish_agent_spec.rb
  121. 43 0
      spec/models/agents/twitter_search_agent_spec.rb
  122. 2 1
      spec/models/agents/twitter_stream_agent_spec.rb
  123. 1 1
      spec/models/agents/twitter_user_agent_spec.rb
  124. 1 1
      spec/models/agents/user_location_agent_spec.rb
  125. 28 0
      spec/models/agents/weather_agent_spec.rb
  126. 23 2
      spec/models/agents/webhook_agent_spec.rb
  127. 164 14
      spec/models/agents/website_agent_spec.rb
  128. 1 1
      spec/models/agents/weibo_publish_agent_spec.rb
  129. 1 1
      spec/models/agents/weibo_user_agent_spec.rb
  130. 1 1
      spec/models/agents/witai_agent_spec.rb
  131. 1 1
      spec/models/agents/wunderlist_agent_spec.rb
  132. 1 1
      spec/models/concerns/oauthable.rb
  133. 1 1
      spec/models/event_spec.rb
  134. 1 1
      spec/models/scenario_import_spec.rb
  135. 1 1
      spec/models/scenario_spec.rb
  136. 1 1
      spec/models/service_spec.rb
  137. 8 8
      spec/models/user_credential_spec.rb
  138. 4 4
      spec/models/users_spec.rb
  139. 1 1
      spec/presenters/form_configurable_agent_presenter_spec.rb
  140. 8 0
      spec/spec_helper.rb
  141. 1 1
      spec/routing/webhooks_controller_spec.rb
  142. 20 1
      spec/support/shared_examples/agent_controller_concern.rb
  143. 1 1
      spec/support/shared_examples/email_concern.rb
  144. 1 1
      spec/support/shared_examples/has_guid.rb
  145. 2 2
      spec/support/shared_examples/liquid_interpolatable.rb
  146. 1 1
      spec/support/shared_examples/web_request_concern.rb
  147. 1 1
      spec/support/shared_examples/working_helpers.rb

+ 6 - 0
.env.example

@@ -167,6 +167,12 @@ EVENT_EXPIRATION_CHECK=6h
167 167
 # enabled.
168 168
 #USE_GRAPHVIZ_DOT=dot
169 169
 
170
+# Default layout for agent flow diagrams generated by Graphviz.
171
+# Choose from `circo`, `dot` (default), `fdp`, `neato`, `osage`,
172
+# `patchwork`, `sfdp`, or `twopi`.  Note that not all layouts are
173
+# supported by Graphviz depending on the build options.
174
+#DIAGRAM_DEFAULT_LAYOUT=dot
175
+
170 176
 # Timezone. Use `rake time:zones:local` or `rake time:zones:all` to get your zone name
171 177
 TIMEZONE="Pacific Time (US & Canada)"
172 178
 

+ 12 - 0
CHANGES.md

@@ -1,5 +1,17 @@
1 1
 # Changes
2 2
 
3
+* Oct 17, 2015   - TwitterSearchAgent added for running period Twitter searches.
4
+* Oct 17, 2015   - GapDetectorAgent added to alert when no data has been seen in a certain period of time.
5
+* Oct 12, 2015   - Slack agent supports attachments.
6
+* Oct 9, 2015    - The TriggerAgent can be asked to match on fewer then all match groups.
7
+* Oct 4, 2015    - Add DelayAgent for buffering incoming Events
8
+* Oct 3, 2015    - Add SSL verification options to smtp.yml
9
+* Oct 3, 2015    - Better handling of 'Back' links in the UI.
10
+* Sep 22, 2015   - Comprehensive EvernoteAgent added
11
+* Sep 13, 2015   - JavaScriptAgent can access and set Credentials.
12
+* Sep 9, 2015    - Add AgentRunner and LongRunnable to support long running agents.
13
+* Sep 8, 2015    - Allow `url_from_event` in the WebsiteAgent to be an Array
14
+* Sep 7, 2015    - Enable `strict: false` in database.yml
3 15
 * Sep 2, 2015    - WebRequestConcern Agents automatically decode gzip/inflate encodings.
4 16
 * Sep 1, 2015    - WebhookAgent can configure allowed verbs (GET, POST, PUT, ...) for incoming requests.
5 17
 * Aug 21, 2015   - PostAgent supports "xml" as `content_type`.

+ 2 - 2
Gemfile

@@ -29,7 +29,7 @@ gem 'twitter-stream', github: 'cantino/twitter-stream', branch: 'huginn'
29 29
 gem 'omniauth-twitter'
30 30
 
31 31
 # Tumblr Agents
32
-gem 'tumblr_client', github: 'knu/tumblr_client', branch: 'patch-1'
32
+gem 'tumblr_client', github: 'tumblr/tumblr_client', branch: 'master'  # '>= 0.8.5'
33 33
 gem 'omniauth-tumblr'
34 34
 
35 35
 # Dropbox Agents
@@ -67,7 +67,7 @@ gem 'devise', '~> 3.4.0'
67 67
 gem 'dotenv-rails', '~> 2.0.1'
68 68
 gem 'em-http-request', '~> 1.1.2'
69 69
 gem 'faraday', '~> 0.9.0'
70
-gem 'faraday_middleware', '>= 0.10.0'
70
+gem 'faraday_middleware', github: 'lostisland/faraday_middleware', branch: 'master'  # '>= 0.10.1'
71 71
 gem 'feed-normalizer'
72 72
 gem 'font-awesome-sass', '~> 4.3.2'
73 73
 gem 'foreman', '~> 0.63.0'

+ 15 - 9
Gemfile.lock

@@ -20,9 +20,17 @@ GIT
20 20
       rest-client (~> 1.8)
21 21
 
22 22
 GIT
23
-  remote: git://github.com/knu/tumblr_client.git
24
-  revision: d6f1f64a7cba381345c588e28ebcff28048c3a6c
25
-  branch: patch-1
23
+  remote: git://github.com/lostisland/faraday_middleware.git
24
+  revision: c5836ae55857272732b33eb0e0a98d60e995a376
25
+  branch: master
26
+  specs:
27
+    faraday_middleware (0.10.0)
28
+      faraday (>= 0.7.4, < 0.10)
29
+
30
+GIT
31
+  remote: git://github.com/tumblr/tumblr_client.git
32
+  revision: 0c59b04e49f2a8c89860613b18cf4e8f978d8dc7
33
+  branch: master
26 34
   specs:
27 35
     tumblr_client (0.8.5)
28 36
       faraday (~> 0.9.0)
@@ -187,8 +195,6 @@ GEM
187 195
     extlib (0.9.16)
188 196
     faraday (0.9.1)
189 197
       multipart-post (>= 1.2, < 3)
190
-    faraday_middleware (0.10.0)
191
-      faraday (>= 0.7.4, < 0.10)
192 198
     feed-normalizer (1.5.2)
193 199
       hpricot (>= 0.6)
194 200
       simple-rss (>= 1.1)
@@ -293,7 +299,7 @@ GEM
293 299
     mime-types (2.6.1)
294 300
     mini_magick (4.2.3)
295 301
     mini_portile (0.6.2)
296
-    minitest (5.8.0)
302
+    minitest (5.8.1)
297 303
     mqtt (0.3.1)
298 304
     multi_json (1.11.2)
299 305
     multi_xml (0.5.5)
@@ -442,8 +448,8 @@ GEM
442 448
       tilt (~> 1.1)
443 449
     select2-rails (3.5.9.3)
444 450
       thor (~> 0.14)
445
-    shoulda-matchers (2.8.0)
446
-      activesupport (>= 3.0.0)
451
+    shoulda-matchers (3.0.0)
452
+      activesupport (>= 4.0.0)
447 453
     signet (0.5.1)
448 454
       addressable (>= 2.2.3)
449 455
       faraday (>= 0.9.0.rc5)
@@ -553,7 +559,7 @@ DEPENDENCIES
553 559
   em-http-request (~> 1.1.2)
554 560
   evernote_oauth
555 561
   faraday (~> 0.9.0)
556
-  faraday_middleware (>= 0.10.0)
562
+  faraday_middleware!
557 563
   feed-normalizer
558 564
   ffi (>= 1.9.4)
559 565
   font-awesome-sass (~> 4.3.2)

+ 1 - 0
app/assets/javascripts/diagram.js.coffee

@@ -3,6 +3,7 @@
3 3
 $ ->
4 4
   svg = document.querySelector('.agent-diagram svg.diagram')
5 5
   overlay = document.querySelector('.agent-diagram .overlay')
6
+  $(overlay).width($(svg).width()).height($(svg).height())
6 7
   getTopLeft = (node) ->
7 8
     bbox = node.getBBox()
8 9
     point = svg.createSVGPoint()

+ 2 - 2
app/concerns/agent_controller_concern.rb

@@ -7,7 +7,7 @@ module AgentControllerConcern
7 7
 
8 8
   def default_options
9 9
     {
10
-      'action' => 'run',
10
+      'action' => 'run'
11 11
     }
12 12
   end
13 13
 
@@ -68,7 +68,7 @@ module AgentControllerConcern
68 68
             log "Agent '#{target.name}' is disabled"
69 69
           end
70 70
         when 'configure'
71
-          target.update!(options: target.options.merge(interpolated['configure_options']))
71
+          target.update! options: target.options.deep_merge(interpolated['configure_options'])
72 72
           log "Agent '#{target.name}' is configured with #{interpolated['configure_options'].inspect}"
73 73
         when ''
74 74
           # Do nothing

+ 21 - 5
app/concerns/long_runnable.rb

@@ -51,12 +51,13 @@ module LongRunnable
51 51
   end
52 52
 
53 53
   class Worker
54
-    attr_reader :thread, :id, :agent, :config, :mutex, :scheduler
54
+    attr_reader :thread, :id, :agent, :config, :mutex, :scheduler, :restarting
55 55
 
56 56
     def initialize(options = {})
57 57
       @id = options[:id]
58 58
       @agent = options[:agent]
59 59
       @config = options[:config]
60
+      @restarting = false
60 61
     end
61 62
 
62 63
     def run
@@ -65,6 +66,7 @@ module LongRunnable
65 66
 
66 67
     def run!
67 68
       @thread = Thread.new do
69
+        Thread.current[:name] = "#{id}-#{Time.now}"
68 70
         begin
69 71
           run
70 72
         rescue SignalException, SystemExit
@@ -90,14 +92,21 @@ module LongRunnable
90 92
       if respond_to?(:stop)
91 93
         stop
92 94
       else
93
-        thread.terminate
95
+        terminate_thread!
94 96
       end
95 97
     end
96 98
 
99
+    def terminate_thread!
100
+      thread.terminate
101
+      thread.wakeup if thread.status == 'sleep'
102
+    end
103
+
97 104
     def restart!
98
-      stop!
99
-      setup!(scheduler, mutex)
100
-      run!
105
+      without_alive_check do
106
+        stop!
107
+        setup!(scheduler, mutex)
108
+        run!
109
+      end
101 110
     end
102 111
 
103 112
     def every(*args, &blk)
@@ -120,5 +129,12 @@ module LongRunnable
120 129
     def schedule(method, args, &blk)
121 130
       @scheduler.send(method, *args, tag: id, &blk)
122 131
     end
132
+
133
+    def without_alive_check(&blk)
134
+      @restarting = true
135
+      yield
136
+    ensure
137
+      @restarting = false
138
+    end
123 139
   end
124 140
 end

+ 1 - 1
app/concerns/sortable_events.rb

@@ -11,7 +11,7 @@ module SortableEvents
11 11
 
12 12
   module ClassMethods
13 13
     def can_order_created_events!
14
-      raise if cannot_create_events?
14
+      raise 'Cannot order events for agent that cannot create events' if cannot_create_events?
15 15
       prepend AutomaticSorter
16 16
     end
17 17
 

+ 2 - 7
app/concerns/web_request_concern.rb

@@ -39,7 +39,7 @@ module WebRequestConcern
39 39
           # detection, so we do that.
40 40
           case env[:response_headers][:content_type]
41 41
           when /;\s*charset\s*=\s*([^()<>@,;:\\\"\/\[\]?={}\s]+)/i
42
-            encoding = Encoding.find($1) rescue nil
42
+            encoding = Encoding.find($1) rescue @default_encoding
43 43
           when /\A\s*(?:text\/[^\s;]+|application\/(?:[^\s;]+\+)?(?:xml|json))\s*(?:;|\z)/i
44 44
             encoding = @default_encoding
45 45
           else
@@ -47,7 +47,7 @@ module WebRequestConcern
47 47
             next
48 48
           end
49 49
         end
50
-        body.encode!(Encoding::UTF_8, encoding) unless body.encoding == Encoding::UTF_8
50
+        body.encode!(Encoding::UTF_8, encoding)
51 51
       end
52 52
     end
53 53
   end
@@ -123,11 +123,6 @@ module WebRequestConcern
123 123
 
124 124
       builder.use FaradayMiddleware::Gzip
125 125
 
126
-      unless builder.headers.any? { |key,| /\Aaccept[-_]encoding\z/i =~ key }
127
-        # Exclude `deflate` by default.  See #1018.
128
-        builder.headers[:accept_encoding] = 'gzip,identity'
129
-      end
130
-
131 126
       case backend = faraday_backend
132 127
         when :typhoeus
133 128
           require 'typhoeus/adapters/faraday'

+ 7 - 5
app/helpers/dot_helper.rb

@@ -1,8 +1,8 @@
1 1
 module DotHelper
2
-  def render_agents_diagram(agents)
2
+  def render_agents_diagram(agents, layout: nil)
3 3
     if (command = ENV['USE_GRAPHVIZ_DOT']) &&
4 4
        (svg = IO.popen([command, *%w[-Tsvg -q1 -o/dev/stdout /dev/stdin]], 'w+') { |dot|
5
-          dot.print agents_dot(agents, true)
5
+          dot.print agents_dot(agents, rich: true, layout: layout)
6 6
           dot.close_write
7 7
           dot.read
8 8
         } rescue false)
@@ -125,7 +125,7 @@ module DotHelper
125 125
     DotDrawer.draw(vars, &block)
126 126
   end
127 127
 
128
-  def agents_dot(agents, rich = false)
128
+  def agents_dot(agents, rich: false, layout: nil)
129 129
     draw(agents: agents,
130 130
          agent_id: ->agent { 'a%d' % agent.id },
131 131
          agent_label: ->agent {
@@ -158,7 +158,10 @@ module DotHelper
158 158
       end
159 159
 
160 160
       block('digraph', 'Agent Event Flow') {
161
-        # statement 'graph', rankdir: 'LR'
161
+        layout ||= ENV['DIAGRAM_DEFAULT_LAYOUT'].presence
162
+        if rich && /\A[a-z]+\z/ === layout
163
+          statement 'graph', layout: layout, overlap: 'false'
164
+        end
162 165
         statement 'node',
163 166
                   shape: 'box',
164 167
                   style: 'rounded',
@@ -197,7 +200,6 @@ module DotHelper
197 200
       root << svg
198 201
       root << overlay_container = Nokogiri::XML::Node.new('div', doc) { |div|
199 202
         div['class'] = 'overlay-container'
200
-        div['style'] = "width: #{svg['width']}; height: #{svg['height']}"
201 203
       }
202 204
       overlay_container << overlay = Nokogiri::XML::Node.new('div', doc) { |div|
203 205
         div['class'] = 'overlay'

+ 128 - 0
app/models/agents/beeper_agent.rb

@@ -0,0 +1,128 @@
1
+module Agents
2
+  class BeeperAgent < Agent
3
+    cannot_be_scheduled!
4
+    cannot_create_events!
5
+
6
+    description <<-MD
7
+      Beeper agent sends messages to Beeper app on your mobile device via Push notifications.
8
+
9
+      You need a Beeper Application ID (`app_id`), Beeper REST API Key (`api_key`) and Beeper Sender ID (`sender_id`) [https://beeper.io](https://beeper.io)
10
+
11
+      You have to provide phone number (`phone`) of the recipient which have a mobile device with Beeper installed, or a `group_id` – Beeper Group ID
12
+
13
+      Also you have to provide a message `type` which has to be `message`, `image`, `event`, `location` or `task`.
14
+
15
+      Depending on message type you have to provide additional fields:
16
+
17
+      ##### Message
18
+      * `text` – **required**
19
+
20
+      ##### Image
21
+      * `image` – **required** (Image URL or Base64-encoded image)
22
+      * `text` – optional
23
+
24
+      ##### Event
25
+      * `text` – **required**
26
+      * `start_time` – **required** (Corresponding to ISO 8601)
27
+      * `end_time` – optional (Corresponding to ISO 8601)
28
+
29
+      ##### Location
30
+      * `latitude` – **required**
31
+      * `longitude` – **required**
32
+      * `text` – optional
33
+
34
+      ##### Task
35
+      * `text` – **required**
36
+
37
+      You can see additional documentation at [Beeper website](https://beeper.io/docs)
38
+    MD
39
+
40
+    BASE_URL = 'https://api.beeper.io/api'
41
+
42
+    TYPE_ATTRIBUTES = {
43
+      'message'  => %w(text),
44
+      'image'    => %w(text image),
45
+      'event'    => %w(text start_time end_time),
46
+      'location' => %w(text latitude longitude),
47
+      'task'     => %w(text)
48
+    }
49
+
50
+    MESSAGE_TYPES = TYPE_ATTRIBUTES.keys
51
+
52
+    TYPE_REQUIRED_ATTRIBUTES = {
53
+      'message'  => %w(text),
54
+      'image'    => %w(image),
55
+      'event'    => %w(text start_time),
56
+      'location' => %w(latitude longitude),
57
+      'task'     => %w(text)
58
+    }
59
+
60
+    def default_options
61
+      {
62
+        'type'      => 'message',
63
+        'app_id'    => '',
64
+        'api_key'   => '',
65
+        'sender_id' => '',
66
+        'phone'     => '',
67
+        'text'      => '{{title}}'
68
+      }
69
+    end
70
+
71
+    def validate_options
72
+      %w(app_id api_key sender_id type).each do |attr|
73
+        errors.add(:base, "you need to specify a #{attr}") if options[attr].blank?
74
+      end
75
+
76
+      if options['type'].in?(MESSAGE_TYPES)
77
+        required_attributes = TYPE_REQUIRED_ATTRIBUTES[options['type']]
78
+        if required_attributes.any? { |attr| options[attr].blank? }
79
+          errors.add(:base, "you need to specify a #{required_attributes.join(', ')}")
80
+        end
81
+      else
82
+        errors.add(:base, 'you need to specify a valid message type')
83
+      end
84
+
85
+      unless options['group_id'].blank? ^ options['phone'].blank?
86
+        errors.add(:base, 'you need to specify a phone or group_id')
87
+      end
88
+    end
89
+
90
+    def working?
91
+      received_event_without_error? && !recent_error_logs?
92
+    end
93
+
94
+    def receive(incoming_events)
95
+      incoming_events.each do |event|
96
+        send_message(event)
97
+      end
98
+    end
99
+
100
+    def send_message(event)
101
+      mo = interpolated(event)
102
+      begin
103
+        response = HTTParty.post(endpoint_for(mo['type']), body: payload_for(mo), headers: headers)
104
+        error(response.body) if response.code != 201
105
+      rescue HTTParty::Error => e
106
+        error(e.message)
107
+      end
108
+    end
109
+
110
+    private
111
+
112
+    def headers
113
+      {
114
+        'X-Beeper-Application-Id' => options['app_id'],
115
+        'X-Beeper-REST-API-Key'   => options['api_key'],
116
+        'Content-Type' => 'application/json'
117
+      }
118
+    end
119
+
120
+    def payload_for(mo)
121
+      mo.slice(*TYPE_ATTRIBUTES[mo['type']], 'sender_id', 'phone', 'group_id').to_json
122
+    end
123
+
124
+    def endpoint_for(type)
125
+      "#{BASE_URL}/#{type}s.json"
126
+    end
127
+  end
128
+end

+ 1 - 1
app/models/agents/commander_agent.rb

@@ -36,7 +36,7 @@ module Agents
36 36
       true
37 37
     end
38 38
 
39
-    def check!
39
+    def check
40 40
       control!
41 41
     end
42 42
 

+ 100 - 28
app/models/agents/data_output_agent.rb

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class DataOutputAgent < Agent
3
+    include WebRequestConcern
4
+
3 5
     cannot_be_scheduled!
4 6
 
5 7
     description  do
@@ -19,9 +21,10 @@ module Agents
19 21
 
20 22
           * `secrets` - An array of tokens that the requestor must provide for light-weight authentication.
21 23
           * `expected_receive_period_in_days` - How often you expect data to be received by this Agent from other Agents.
22
-          * `template` - A JSON object representing a mapping between item output keys and incoming event values.  Use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the values.  Values of the `link`, `title`, `description` and `icon` keys will be put into the \\<channel\\> section of RSS output.  The `item` key will be repeated for every Event.  The `pubDate` key for each item will have the creation time of the Event unless given.
24
+          * `template` - A JSON object representing a mapping between item output keys and incoming event values.  Use [Liquid](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to format the values.  Values of the `link`, `title`, `description` and `icon` keys will be put into the \\<channel\\> section of RSS output.  Value of the `self` key will be used as URL for this feed itself, which is useful when you serve it via reverse proxy.  The `item` key will be repeated for every Event.  The `pubDate` key for each item will have the creation time of the Event unless given.
23 25
           * `events_to_show` - The number of events to output in RSS or JSON. (default: `40`)
24 26
           * `ttl` - A value for the \\<ttl\\> element in RSS output. (default: `60`)
27
+          * `push_hubs` - Set to a list of PubSubHubbub endpoints you want to publish an update to every time this agent receives an event. (default: none)  Popular hubs include [Superfeedr](https://pubsubhubbub.superfeedr.com/) and [Google](https://pubsubhubbub.appspot.com/).  Note that publishing updates will make your feed URL known to the public, so if you want to keep it secret, set up a reverse proxy to serve your feed via a safe URL and specify it in `template.self`.
25 28
 
26 29
         If you'd like to output RSS tags with attributes, such as `enclosure`, use something like the following in your `template`:
27 30
 
@@ -95,6 +98,29 @@ module Agents
95 98
       unless options['template'].present? && options['template']['item'].present? && options['template']['item'].is_a?(Hash)
96 99
         errors.add(:base, "Please provide template and template.item")
97 100
       end
101
+
102
+      case options['push_hubs']
103
+      when nil
104
+      when Array
105
+        options['push_hubs'].each do |hub|
106
+          case hub
107
+          when /\{/
108
+            # Liquid templating
109
+          when String
110
+            begin
111
+              URI.parse(hub)
112
+            rescue URI::Error
113
+              errors.add(:base, "invalid URL found in push_hubs")
114
+              break
115
+            end
116
+          else
117
+            errors.add(:base, "push_hubs must be an array of endpoint URLs")
118
+            break
119
+          end
120
+        end
121
+      else
122
+        errors.add(:base, "push_hubs must be an array")
123
+      end
98 124
     end
99 125
 
100 126
     def events_to_show
@@ -114,11 +140,12 @@ module Agents
114 140
     end
115 141
 
116 142
     def feed_url(options = {})
117
-      feed_link + Rails.application.routes.url_helpers.
118
-                  web_requests_path(agent_id: id || ':id',
119
-                                    user_id: user_id,
120
-                                    secret: options[:secret],
121
-                                    format: options[:format])
143
+      interpolated['template']['self'].presence ||
144
+        feed_link + Rails.application.routes.url_helpers.
145
+                    web_requests_path(agent_id: id || ':id',
146
+                                      user_id: user_id,
147
+                                      secret: options[:secret],
148
+                                      format: options[:format])
122 149
     end
123 150
 
124 151
     def feed_icon
@@ -129,6 +156,10 @@ module Agents
129 156
       interpolated['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
130 157
     end
131 158
 
159
+    def push_hubs
160
+      interpolated['push_hubs'].presence || []
161
+    end
162
+
132 163
     def receive_web_request(params, method, format)
133 164
       unless interpolated['secrets'].include?(params['secret'])
134 165
         if format =~ /json/
@@ -159,40 +190,54 @@ module Agents
159 190
           interpolated
160 191
         end
161 192
 
193
+        now = Time.now
194
+
162 195
         if format =~ /json/
163 196
           content = {
164 197
             'title' => feed_title,
165 198
             'description' => feed_description,
166
-            'pubDate' => Time.now,
199
+            'pubDate' => now,
167 200
             'items' => simplify_item_for_json(items)
168 201
           }
169 202
 
170 203
           return [content, 200]
171 204
         else
172
-          content = Utils.unindent(<<-XML)
173
-            <?xml version="1.0" encoding="UTF-8" ?>
174
-            <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
175
-            <channel>
176
-             <atom:link href=#{feed_url(secret: params['secret'], format: :xml).encode(xml: :attr)} rel="self" type="application/rss+xml" />
177
-             <atom:icon>#{feed_icon.encode(xml: :text)}</atom:icon>
178
-             <title>#{feed_title.encode(xml: :text)}</title>
179
-             <description>#{feed_description.encode(xml: :text)}</description>
180
-             <link>#{feed_link.encode(xml: :text)}</link>
181
-             <lastBuildDate>#{Time.now.rfc2822.to_s.encode(xml: :text)}</lastBuildDate>
182
-             <pubDate>#{Time.now.rfc2822.to_s.encode(xml: :text)}</pubDate>
183
-             <ttl>#{feed_ttl}</ttl>
184
-
205
+          hub_links = push_hubs.map { |hub|
206
+            <<-XML
207
+ <atom:link rel="hub" href=#{hub.encode(xml: :attr)}/>
208
+            XML
209
+          }.join
210
+
211
+          items = simplify_item_for_xml(items)
212
+                  .to_xml(skip_types: true, root: "items", skip_instruct: true, indent: 1)
213
+                  .gsub(%r{^</?items>\n}, '')
214
+
215
+          return [<<-XML, 200, 'text/xml']
216
+<?xml version="1.0" encoding="UTF-8" ?>
217
+<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
218
+<channel>
219
+ <atom:link href=#{feed_url(secret: params['secret'], format: :xml).encode(xml: :attr)} rel="self" type="application/rss+xml" />
220
+ <atom:icon>#{feed_icon.encode(xml: :text)}</atom:icon>
221
+#{hub_links}
222
+ <title>#{feed_title.encode(xml: :text)}</title>
223
+ <description>#{feed_description.encode(xml: :text)}</description>
224
+ <link>#{feed_link.encode(xml: :text)}</link>
225
+ <lastBuildDate>#{now.rfc2822.to_s.encode(xml: :text)}</lastBuildDate>
226
+ <pubDate>#{now.rfc2822.to_s.encode(xml: :text)}</pubDate>
227
+ <ttl>#{feed_ttl}</ttl>
228
+#{items}
229
+</channel>
230
+</rss>
185 231
           XML
232
+        end
233
+      end
234
+    end
186 235
 
187
-          content += simplify_item_for_xml(items).to_xml(skip_types: true, root: "items", skip_instruct: true, indent: 1).gsub(/^<\/?items>/, '').strip
188
-
189
-          content += Utils.unindent(<<-XML)
190
-            </channel>
191
-            </rss>
192
-          XML
236
+    def receive(incoming_events)
237
+      url = feed_url(secret: interpolated['secrets'].first, format: :xml)
193 238
 
194
-          return [content, 200, 'text/xml']
195
-        end
239
+      push_hubs.each do |hub|
240
+        push_to_hub(hub, url)
196 241
       end
197 242
     end
198 243
 
@@ -261,5 +306,32 @@ module Agents
261 306
         item
262 307
       end
263 308
     end
309
+
310
+    def push_to_hub(hub, url)
311
+      hub_uri =
312
+        begin
313
+          URI.parse(hub)
314
+        rescue URI::Error
315
+          nil
316
+        end
317
+
318
+      if !hub_uri.is_a?(URI::HTTP)
319
+        error "Invalid push endpoint: #{hub}"
320
+        return
321
+      end
322
+
323
+      log "Pushing #{url} to #{hub_uri}"
324
+
325
+      return if dry_run?
326
+
327
+      begin
328
+        faraday.post hub_uri, {
329
+          'hub.mode' => 'publish',
330
+          'hub.url' => url
331
+        }
332
+     rescue => e
333
+       error "Push failed: #{e.message}"
334
+      end
335
+    end
264 336
   end
265 337
 end

+ 73 - 0
app/models/agents/delay_agent.rb

@@ -0,0 +1,73 @@
1
+module Agents
2
+  class DelayAgent < Agent
3
+    default_schedule "every_12h"
4
+
5
+    description <<-MD
6
+      The DelayAgent stores received Events and emits copies of them on a schedule. Use this as a buffer or queue of Events.
7
+
8
+      `max_events` should be set to the maximum number of events that you'd like to hold in the buffer. When this number is
9
+      reached, new events will either be ignored, or will displace the oldest event already in the buffer, depending on
10
+      whether you set `keep` to `newest` or `oldest`.
11
+
12
+      `expected_receive_period_in_days` is used to determine if the Agent is working. Set it to the maximum number of days
13
+      that you anticipate passing without this Agent receiving an incoming Event.
14
+
15
+      `max_emitted_events` is used to limit the number of the maximum events which should be created. If you omit this DelayAgent will create events for every event stored in the memory.
16
+    MD
17
+
18
+    def default_options
19
+      {
20
+        'expected_receive_period_in_days' => "10",
21
+        'max_events' => "100",
22
+        'keep' => 'newest'
23
+      }
24
+    end
25
+
26
+    def validate_options
27
+      unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
28
+        errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
29
+      end
30
+
31
+      unless options['keep'].present? && options['keep'].in?(%w[newest oldest])
32
+        errors.add(:base, "The 'keep' option is required and must be set to 'oldest' or 'newest'")
33
+      end
34
+
35
+      unless options['max_events'].present? && options['max_events'].to_i > 0
36
+        errors.add(:base, "The 'max_events' option is required and must be an integer greater than 0")
37
+      end
38
+    end
39
+
40
+    def working?
41
+      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
42
+    end
43
+
44
+    def receive(incoming_events)
45
+      incoming_events.each do |event|
46
+        memory['event_ids'] ||= []
47
+        memory['event_ids'] << event.id
48
+        if memory['event_ids'].length > interpolated['max_events'].to_i
49
+          if interpolated['keep'] == 'newest'
50
+            memory['event_ids'].shift
51
+          else
52
+            memory['event_ids'].pop
53
+          end
54
+        end
55
+      end
56
+    end
57
+
58
+    def check
59
+      if memory['event_ids'] && memory['event_ids'].length > 0
60
+        events = received_events.where(id: memory['event_ids']).reorder('events.id asc')
61
+
62
+        if options['max_emitted_events'].present?
63
+          events = events.limit(options['max_emitted_events'].to_i)
64
+        end
65
+
66
+        events.each do |event|
67
+          create_event payload: event.payload
68
+          memory['event_ids'].delete(event.id)
69
+        end
70
+      end
71
+    end
72
+  end
73
+end

+ 1 - 0
app/models/agents/event_formatting_agent.rb

@@ -1,6 +1,7 @@
1 1
 module Agents
2 2
   class EventFormattingAgent < Agent
3 3
     cannot_be_scheduled!
4
+    can_dry_run!
4 5
 
5 6
     description <<-MD
6 7
       The Event Formatting Agent allows you to format incoming Events, adding new fields as needed.

+ 1 - 1
app/models/agents/ftpsite_agent.rb

@@ -196,7 +196,7 @@ module Agents
196 196
     end
197 197
 
198 198
     def uri_path_escape(string)
199
-      str = string.dup.force_encoding(Encoding::ASCII_8BIT)  # string.b in Ruby >=2.0
199
+      str = string.b
200 200
       str.gsub!(/([^A-Za-z0-9\-._~!$&()*+,=@]+)/) { |m|
201 201
         '%' + m.unpack('H2' * m.bytesize).join('%').upcase
202 202
       }

+ 67 - 0
app/models/agents/gap_detector_agent.rb

@@ -0,0 +1,67 @@
1
+module Agents
2
+  class GapDetectorAgent < Agent
3
+    default_schedule "every_10m"
4
+
5
+    description <<-MD
6
+      The Gap Detector Agent will watch for holes or gaps in a stream of incoming Events and generate "no data alerts".
7
+
8
+      The `value_path` value is a [JSONPath](http://goessner.net/articles/JsonPath/) to a value of interest. If either
9
+      this value is empty, or no Events are received, during `window_duration_in_days`, an Event will be created with
10
+      a payload of `message`.
11
+    MD
12
+
13
+    event_description <<-MD
14
+      Events look like:
15
+
16
+          {
17
+            "message": "No data has been received!",
18
+            "gap_started_at": "1234567890"
19
+          }
20
+    MD
21
+
22
+    def validate_options
23
+      unless options['message'].present?
24
+        errors.add(:base, "message is required")
25
+      end
26
+
27
+      unless options['window_duration_in_days'].present? && options['window_duration_in_days'].to_f > 0
28
+        errors.add(:base, "window_duration_in_days must be provided as an integer or floating point number")
29
+      end
30
+    end
31
+
32
+    def default_options
33
+      {
34
+        'window_duration_in_days' => "2",
35
+        'message' => "No data has been received!"
36
+      }
37
+    end
38
+
39
+    def working?
40
+      true
41
+    end
42
+
43
+    def receive(incoming_events)
44
+      incoming_events.sort_by(&:created_at).each do |event|
45
+        memory['newest_event_created_at'] ||= 0
46
+
47
+        if !interpolated['value_path'].present? || Utils.value_at(event.payload, interpolated['value_path']).present?
48
+          if event.created_at.to_i > memory['newest_event_created_at']
49
+            memory['newest_event_created_at'] = event.created_at.to_i
50
+            memory.delete('alerted_at')
51
+          end
52
+        end
53
+      end
54
+    end
55
+
56
+    def check
57
+      window = interpolated['window_duration_in_days'].to_f.days.ago
58
+      if memory['newest_event_created_at'].present? && Time.at(memory['newest_event_created_at']) < window
59
+        unless memory['alerted_at']
60
+          memory['alerted_at'] = Time.now.to_i
61
+          create_event payload: { message: interpolated['message'],
62
+                                  gap_started_at: memory['newest_event_created_at'] }
63
+        end
64
+      end
65
+    end
66
+  end
67
+end

+ 1 - 1
app/models/agents/peak_detector_agent.rb

@@ -7,7 +7,7 @@ module Agents
7 7
     description <<-MD
8 8
       The Peak Detector Agent will watch for peaks in an event stream.  When a peak is detected, the resulting Event will have a payload message of `message`.  You can include extractions in the message, for example: `I saw a bar of: {{foo.bar}}`, have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) for details.
9 9
 
10
-      The `value_path` value is a [JSONPaths](http://goessner.net/articles/JsonPath/) to the value of interest.  `group_by_path` is a hash path that will be used to group values, if present.
10
+      The `value_path` value is a [JSONPath](http://goessner.net/articles/JsonPath/) to the value of interest.  `group_by_path` is a JSONPath that will be used to group values, if present.
11 11
 
12 12
       Set `expected_receive_period_in_days` to the maximum amount of time that you'd expect to pass between Events being received by this Agent.
13 13
 

+ 26 - 19
app/models/agents/rss_agent.rb

@@ -87,34 +87,41 @@ module Agents
87 87
     end
88 88
 
89 89
     def check
90
-      Array(interpolated['url']).each do |url|
91
-        check_url(url)
92
-      end
90
+      check_urls(Array(interpolated['url']))
93 91
     end
94 92
 
95 93
     protected
96 94
 
97
-    def check_url(url)
98
-      response = faraday.get(url)
99
-      if response.success?
100
-        feed = FeedNormalizer::FeedNormalizer.parse(response.body, loose: true)
101
-        feed.clean! if boolify(interpolated['clean'])
102
-        max_events = (interpolated['max_events_per_run'].presence || 0).to_i
103
-        created_event_count = 0
104
-        sort_events(feed_to_events(feed)).each.with_index do |event, index|
105
-          break if max_events && max_events > 0 && index >= max_events
106
-          entry_id = event.payload[:id]
107
-          if check_and_track(entry_id)
95
+    def check_urls(urls)
96
+      new_events = []
97
+      max_events = (interpolated['max_events_per_run'].presence || 0).to_i
98
+
99
+      urls.each do |url|
100
+        begin
101
+          response = faraday.get(url)
102
+          if response.success?
103
+            feed = FeedNormalizer::FeedNormalizer.parse(response.body, loose: true)
104
+            feed.clean! if boolify(interpolated['clean'])
105
+            new_events.concat feed_to_events(feed)
106
+          else
107
+            error "Failed to fetch #{url}: #{response.inspect}"
108
+          end
109
+        rescue => e
110
+          error "Failed to fetch #{url} with message '#{e.message}': #{e.backtrace}"
111
+        end
112
+      end
113
+
114
+      created_event_count = 0
115
+      sort_events(new_events).each.with_index do |event, index|
116
+        entry_id = event.payload[:id]
117
+        if check_and_track(entry_id)
118
+          unless max_events && max_events > 0 && index >= max_events
108 119
             created_event_count += 1
109 120
             create_event(event)
110 121
           end
111 122
         end
112
-        log "Fetched #{url} and created #{created_event_count} event(s)."
113
-      else
114
-        error "Failed to fetch #{url}: #{response.inspect}"
115 123
       end
116
-    rescue => e
117
-      error "Failed to fetch #{url} with message '#{e.message}': #{e.backtrace}"
124
+      log "Fetched #{urls.to_sentence} and created #{created_event_count} event(s)."
118 125
     end
119 126
 
120 127
     def get_entry_id(entry)

+ 0 - 4
app/models/agents/scheduler_agent.rb

@@ -87,10 +87,6 @@ module Agents
87 87
       true
88 88
     end
89 89
 
90
-    def check!
91
-      control!
92
-    end
93
-
94 90
     def validate_options
95 91
       if (spec = options['schedule']).present?
96 92
         begin

+ 64 - 24
app/models/agents/shell_command_agent.rb

@@ -1,9 +1,9 @@
1
-require 'open3'
2
-
3 1
 module Agents
4 2
   class ShellCommandAgent < Agent
5 3
     default_schedule "never"
6 4
 
5
+    can_dry_run!
6
+
7 7
     def self.should_run?
8 8
       ENV['ENABLE_INSECURE_AGENTS'] == "true"
9 9
     end
@@ -11,7 +11,7 @@ module Agents
11 11
     description <<-MD
12 12
       The Shell Command Agent will execute commands on your local system, returning the output.
13 13
 
14
-      `command` specifies the command to be executed, and `path` will tell ShellCommandAgent in what directory to run this command.
14
+      `command` specifies the command (either a shell command line string or an array of command line arguments) to be executed, and `path` will tell ShellCommandAgent in what directory to run this command.  The content of `stdin` will be fed to the command via the standard input.
15 15
 
16 16
       `expected_update_period_in_days` is used to determine if the Agent is working.
17 17
 
@@ -20,6 +20,10 @@ module Agents
20 20
 
21 21
       The resulting event will contain the `command` which was executed, the `path` it was executed under, the `exit_status` of the command, the `errors`, and the actual `output`. ShellCommandAgent will not log an error if the result implies that something went wrong.
22 22
 
23
+      If `suppress_on_failure` is set to true, no event is emitted when `exit_status` is not zero.
24
+
25
+      If `suppress_on_empty_output` is set to true, no event is emitted when `output` is empty.
26
+
23 27
       *Warning*: This type of Agent runs arbitrary commands on your system, #{Agents::ShellCommandAgent.should_run? ? "but is **currently enabled**" : "and is **currently disabled**"}.
24 28
       Only enable this Agent if you trust everyone using your Huginn installation.
25 29
       You can enable this Agent in your .env file by setting `ENABLE_INSECURE_AGENTS` to `true`.
@@ -31,7 +35,7 @@ module Agents
31 35
         {
32 36
           "command": "pwd",
33 37
           "path": "/home/Huginn",
34
-          "exit_status": "0",
38
+          "exit_status": 0,
35 39
           "errors": "",
36 40
           "output": "/home/Huginn"
37 41
         }
@@ -41,6 +45,8 @@ module Agents
41 45
       {
42 46
           'path' => "/",
43 47
           'command' => "pwd",
48
+          'suppress_on_failure' => false,
49
+          'suppress_on_empty_output' => false,
44 50
           'expected_update_period_in_days' => 1
45 51
       }
46 52
     end
@@ -50,6 +56,16 @@ module Agents
50 56
         errors.add(:base, "The path, command, and expected_update_period_in_days fields are all required.")
51 57
       end
52 58
 
59
+      case options['stdin']
60
+      when String, nil
61
+      else
62
+        errors.add(:base, "stdin must be a string.")
63
+      end
64
+
65
+      unless Array(options['command']).all? { |o| o.is_a?(String) }
66
+        errors.add(:base, "command must be a shell command line string or an array of command line arguments.")
67
+      end
68
+
53 69
       unless File.directory?(options['path'])
54 70
         errors.add(:base, "#{options['path']} is not a real directory.")
55 71
       end
@@ -75,38 +91,62 @@ module Agents
75 91
       if Agents::ShellCommandAgent.should_run?
76 92
         command = opts['command']
77 93
         path = opts['path']
94
+        stdin = opts['stdin']
95
+
96
+        result, errors, exit_status = run_command(path, command, stdin)
78 97
 
79
-        result, errors, exit_status = run_command(path, command)
98
+        payload = {
99
+          'command' => command,
100
+          'path' => path,
101
+          'exit_status' => exit_status,
102
+          'errors' => errors,
103
+          'output' => result,
104
+        }
80 105
 
81
-        vals = {"command" => command, "path" => path, "exit_status" => exit_status, "errors" => errors, "output" => result}
82
-        created_event = create_event :payload => vals
106
+        unless suppress_event?(payload)
107
+          created_event = create_event payload: payload
108
+        end
83 109
 
84
-        log("Ran '#{command}' under '#{path}'", :outbound_event => created_event, :inbound_event => event)
110
+        log("Ran '#{command}' under '#{path}'", outbound_event: created_event, inbound_event: event)
85 111
       else
86 112
         log("Unable to run because insecure agents are not enabled.  Edit ENABLE_INSECURE_AGENTS in the Huginn .env configuration.")
87 113
       end
88 114
     end
89 115
 
90
-    def run_command(path, command)
91
-      result = nil
92
-      errors = nil
93
-      exit_status = nil
94
-
95
-      Dir.chdir(path){
96
-        begin
97
-          stdin, stdout, stderr, wait_thr = Open3.popen3(command)
98
-          exit_status = wait_thr.value.to_i
99
-          result = stdout.gets(nil)
100
-          errors = stderr.gets(nil)
101
-        rescue Exception => e
102
-          errors = e.to_s
116
+    def run_command(path, command, stdin)
117
+      begin
118
+        rout, wout = IO.pipe
119
+        rerr, werr = IO.pipe
120
+        rin,  win = IO.pipe
121
+
122
+        pid = spawn(*command, chdir: path, out: wout, err: werr, in: rin)
123
+
124
+        wout.close
125
+        werr.close
126
+        rin.close
127
+
128
+        if stdin
129
+          win.write stdin
130
+          win.close
103 131
         end
104
-      }
105 132
 
106
-      result = result.to_s.strip
107
-      errors = errors.to_s.strip
133
+        (result = rout.read).strip!
134
+        (errors = rerr.read).strip!
135
+
136
+        _, status = Process.wait2(pid)
137
+        exit_status = status.exitstatus
138
+      rescue => e
139
+        errors = e.to_s
140
+        result = ''.freeze
141
+        exit_status = nil
142
+      end
108 143
 
109 144
       [result, errors, exit_status]
110 145
     end
146
+
147
+    def suppress_event?(payload)
148
+      (boolify(interpolated['suppress_on_failure']) && payload['exit_status'].nonzero?) ||
149
+        (boolify(interpolated['suppress_on_empty_output']) && payload['output'].empty?)
150
+    end
111 151
   end
112 152
 end

+ 14 - 5
app/models/agents/slack_agent.rb

@@ -1,6 +1,7 @@
1 1
 module Agents
2 2
   class SlackAgent < Agent
3 3
     DEFAULT_USERNAME = 'Huginn'
4
+    ALLOWED_PARAMS = ['channel', 'username', 'unfurl_links', 'attachments']
4 5
 
5 6
     cannot_be_scheduled!
6 7
     cannot_create_events!
@@ -13,7 +14,7 @@ module Agents
13 14
       #{'## Include `slack-notifier` in your Gemfile to use this Agent!' if dependencies_missing?}
14 15
 
15 16
       To get started, you will first need to configure an incoming webhook.
16
-      
17
+
17 18
       - Go to `https://my.slack.com/services/new/incoming-webhook`, choose a default channel and add the integration.
18 19
 
19 20
       Your webhook URL will look like: `https://hooks.slack.com/services/some/random/characters`
@@ -65,14 +66,22 @@ module Agents
65 66
       @slack_notifier ||= Slack::Notifier.new(webhook_url, username: username)
66 67
     end
67 68
 
69
+    def filter_options(opts)
70
+      opts.select { |key, value| ALLOWED_PARAMS.include? key }.symbolize_keys
71
+    end
72
+
68 73
     def receive(incoming_events)
69 74
       incoming_events.each do |event|
70 75
         opts = interpolated(event)
71
-        if /^:/.match(opts[:icon])
72
-          slack_notifier.ping opts[:message], channel: opts[:channel], username: opts[:username], icon_emoji: opts[:icon], unfurl_links: opts[:unfurl_links]
73
-        else
74
-          slack_notifier.ping opts[:message], channel: opts[:channel], username: opts[:username], icon_url: opts[:icon], unfurl_links: opts[:unfurl_links]
76
+        slack_opts = filter_options(opts)
77
+        if opts[:icon].present?
78
+          if /^:/.match(opts[:icon])
79
+            slack_opts[:icon_emoji] = opts[:icon]
80
+          else
81
+            slack_opts[:icon_url] = opts[:icon]
82
+          end
75 83
         end
84
+        slack_notifier.ping opts[:message], slack_opts
76 85
       end
77 86
     end
78 87
   end

+ 23 - 4
app/models/agents/trigger_agent.rb

@@ -13,7 +13,10 @@ module Agents
13 13
 
14 14
       The `value` can be a single value or an array of values. In the case of an array, if one or more values match then the rule matches.
15 15
 
16
-      All rules must match for the Agent to match.  The resulting Event will have a payload message of `message`.  You can use liquid templating in the `message, have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) for details.
16
+      By default, all rules must match for the Agent to trigger. You can switch this so that only one rule must match by
17
+      setting `must_match` to `1`.
18
+
19
+      The resulting Event will have a payload message of `message`.  You can use liquid templating in the `message, have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) for details.
17 20
 
18 21
       Set `keep_event` to `true` if you'd like to re-emit the incoming event, optionally merged with 'message' when provided.
19 22
 
@@ -35,6 +38,14 @@ module Agents
35 38
       errors.add(:base, "message is required unless 'keep_event' is 'true'") unless options['message'].present? || keep_event?
36 39
 
37 40
       errors.add(:base, "keep_event, when present, must be 'true' or 'false'") unless options['keep_event'].blank? || %w[true false].include?(options['keep_event'])
41
+
42
+      if options['must_match'].present?
43
+        if options['must_match'].to_i < 1
44
+          errors.add(:base, "If used, the 'must_match' option must be a positive integer")
45
+        elsif options['must_match'].to_i > options['rules'].length
46
+          errors.add(:base, "If used, the 'must_match' option must be equal to or less than the number of rules")
47
+        end
48
+      end
38 49
     end
39 50
 
40 51
     def default_options
@@ -59,12 +70,12 @@ module Agents
59 70
 
60 71
         opts = interpolated(event)
61 72
 
62
-        match = opts['rules'].all? do |rule|
73
+        match_results = opts['rules'].map do |rule|
63 74
           value_at_path = Utils.value_at(event['payload'], rule['path'])
64 75
           rule_values = rule['value']
65 76
           rule_values = [rule_values] unless rule_values.is_a?(Array)
66 77
 
67
-          match_found = rule_values.any? do |rule_value|
78
+          rule_values.any? do |rule_value|
68 79
             case rule['type']
69 80
             when "regex"
70 81
               value_at_path.to_s =~ Regexp.new(rule_value, Regexp::IGNORECASE)
@@ -88,7 +99,7 @@ module Agents
88 99
           end
89 100
         end
90 101
 
91
-        if match
102
+        if matches?(match_results)
92 103
           if keep_event?
93 104
             payload = event.payload.dup
94 105
             payload['message'] = opts['message'] if opts['message'].present?
@@ -101,6 +112,14 @@ module Agents
101 112
       end
102 113
     end
103 114
 
115
+    def matches?(matches)
116
+      if options['must_match'].present?
117
+        matches.select { |match| match }.length >= options['must_match'].to_i
118
+      else
119
+        matches.all?
120
+      end
121
+    end
122
+
104 123
     def keep_event?
105 124
       boolify(interpolated['keep_event'])
106 125
     end

+ 29 - 7
app/models/agents/tumblr_publish_agent.rb

@@ -17,9 +17,9 @@ module Agents
17 17
 
18 18
       **Required fields:**
19 19
 
20
-      `blog_name` Your Tumblr URL (e.g. "mustardhamsters.tumblr.com") 
20
+      `blog_name` Your Tumblr URL (e.g. "mustardhamsters.tumblr.com")
21 21
 
22
-      `post_type` One of [text, photo, quote, link, chat, audio, video] 
22
+      `post_type` One of [text, photo, quote, link, chat, audio, video, reblog]
23 23
 
24 24
 
25 25
       -------------
@@ -35,13 +35,13 @@ module Agents
35 35
       * `format` html, markdown
36 36
       * `slug` short text summary at end of the post URL
37 37
 
38
-      **Text** `title` `body` 
38
+      **Text** `title` `body`
39 39
 
40 40
       **Photo** `caption` `link`  `source`
41 41
 
42 42
       **Quote** `quote` `source`
43 43
 
44
-      **Link** `title` `url` `description` 
44
+      **Link** `title` `url` `description`
45 45
 
46 46
       **Chat** `title` `conversation`
47 47
 
@@ -49,6 +49,7 @@ module Agents
49 49
 
50 50
       **Video** `caption` `embed`
51 51
 
52
+      **Reblog** `id` `reblog_key` `comment`
52 53
 
53 54
       -------------
54 55
 
@@ -90,6 +91,9 @@ module Agents
90 91
           'conversation' => "{{conversation}}",
91 92
           'external_url' => "{{external_url}}",
92 93
           'embed' => "{{embed}}",
94
+          'id' => "{{id}}",
95
+          'reblog_key' => "{{reblog_key}}",
96
+          'comment' => "{{comment}}",
93 97
         },
94 98
       }
95 99
     end
@@ -105,19 +109,25 @@ module Agents
105 109
         options = interpolated(event)['options']
106 110
         begin
107 111
           post = publish_post(blog_name, post_type, options)
112
+          if !post.has_key?('id')
113
+            log("Failed to create #{post_type} post on #{blog_name}: #{post.to_json}, options: #{options.to_json}")
114
+            return
115
+          end
116
+          expanded_post = get_post(blog_name, post["id"])
108 117
           create_event :payload => {
109 118
             'success' => true,
110 119
             'published_post' => "["+blog_name+"] "+post_type,
111 120
             'post_id' => post["id"],
112 121
             'agent_id' => event.agent_id,
113
-            'event_id' => event.id
122
+            'event_id' => event.id,
123
+            'post' => expanded_post
114 124
           }
115 125
         end
116 126
       end
117 127
     end
118 128
 
119
-    def publish_post(blog_name, post_type, options)      
120
-      options_obj = { 
129
+    def publish_post(blog_name, post_type, options)
130
+      options_obj = {
121 131
           :state => options['state'],
122 132
           :tags => options['tags'],
123 133
           :tweet => options['tweet'],
@@ -157,7 +167,19 @@ module Agents
157 167
         options_obj[:caption] = options['caption']
158 168
         options_obj[:embed] = options['embed']
159 169
         tumblr.video(blog_name, options_obj)
170
+      when "reblog"
171
+        options_obj[:id] = options['id']
172
+        options_obj[:reblog_key] = options['reblog_key']
173
+        options_obj[:comment] = options['comment']
174
+        tumblr.reblog(blog_name, options_obj)
160 175
       end
161 176
     end
177
+
178
+    def get_post(blog_name, id)
179
+      obj = tumblr.posts(blog_name, {
180
+        :id => id
181
+      })
182
+      obj["posts"].first
183
+    end
162 184
   end
163 185
 end

+ 105 - 0
app/models/agents/twitter_search_agent.rb

@@ -0,0 +1,105 @@
1
+module Agents
2
+  class TwitterSearchAgent < Agent
3
+    include TwitterConcern
4
+
5
+    cannot_receive_events!
6
+
7
+    description <<-MD
8
+      The Twitter Search Agent performs and emits the results of a specified Twitter search.
9
+
10
+      #{twitter_dependencies_missing if dependencies_missing?}
11
+
12
+      If you want realtime data from Twitter about frequent terms, you should definitely use the Twitter Stream Agent instead.
13
+
14
+      To be able to use this Agent you need to authenticate with Twitter in the [Services](/services) section first.
15
+
16
+      You must provide the desired `search`.
17
+      
18
+      Set `result_type` to specify which [type of search results](https://dev.twitter.com/rest/reference/get/search/tweets) you would prefer to receive. Options are "mixed", "recent", and "popular". (default: `mixed`)
19
+
20
+      Set `max_results` to limit the amount of results to retrieve per run(default: `500`. The API rate limit is ~18,000 per 15 minutes. [Click here to learn more about rate limits](https://dev.twitter.com/rest/public/rate-limiting).
21
+
22
+      Set `expected_update_period_in_days` to the maximum amount of time that you'd expect to pass between Events being created by this Agent.
23
+
24
+      Set `starting_at` to the date/time (eg. `Mon Jun 02 00:38:12 +0000 2014`) you want to start receiving tweets from (default: agent's `created_at`)
25
+    MD
26
+
27
+    event_description <<-MD
28
+      Events are the raw JSON provided by the [Twitter API](https://dev.twitter.com/rest/reference/get/search/tweets). Should look something like:
29
+
30
+          {
31
+             ... every Tweet field, including ...
32
+            "text": "something",
33
+            "user": {
34
+              "name": "Mr. Someone",
35
+              "screen_name": "Someone",
36
+              "location": "Vancouver BC Canada",
37
+              "description":  "...",
38
+              "followers_count": 486,
39
+              "friends_count": 1983,
40
+              "created_at": "Mon Aug 29 23:38:14 +0000 2011",
41
+              "time_zone": "Pacific Time (US & Canada)",
42
+              "statuses_count": 3807,
43
+              "lang": "en"
44
+            },
45
+            "retweet_count": 0,
46
+            "entities": ...
47
+            "lang": "en"
48
+          }
49
+    MD
50
+
51
+    default_schedule "every_1h"
52
+
53
+    def working?
54
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
55
+    end
56
+
57
+    def default_options
58
+      {
59
+        'search' => 'freebandnames',
60
+        'expected_update_period_in_days' => '2'
61
+      }
62
+    end
63
+
64
+    def validate_options
65
+      errors.add(:base, "search is required") unless options['search'].present?
66
+      errors.add(:base, "expected_update_period_in_days is required") unless options['expected_update_period_in_days'].present?
67
+
68
+      if options[:starting_at].present?
69
+        Time.parse(interpolated[:starting_at]) rescue errors.add(:base, "Error parsing starting_at")
70
+      end
71
+    end
72
+
73
+    def starting_at
74
+      if interpolated[:starting_at].present?
75
+        Time.parse(interpolated[:starting_at]) rescue created_at
76
+      else
77
+        created_at
78
+      end
79
+    end
80
+
81
+    def max_results
82
+      (interpolated['max_results'].presence || 500).to_i
83
+    end
84
+
85
+    def check
86
+      since_id = memory['since_id'] || nil
87
+      opts = {include_entities: true}
88
+      opts.merge! result_type: interpolated[:result_type] if interpolated[:result_type].present?
89
+      opts.merge! since_id: since_id unless since_id.nil?
90
+
91
+      # http://www.rubydoc.info/gems/twitter/Twitter/REST/Search
92
+      tweets = twitter.search(interpolated['search'], opts).take(max_results)
93
+
94
+      tweets.each do |tweet|
95
+        if (tweet.created_at >= starting_at)
96
+          memory['since_id'] = tweet.id if !memory['since_id'] || (tweet.id > memory['since_id'])
97
+
98
+          create_event payload: tweet.attrs
99
+        end
100
+      end
101
+
102
+      save!
103
+    end
104
+  end
105
+end

+ 7 - 7
app/models/agents/twitter_stream_agent.rb

@@ -125,13 +125,13 @@ module Agents
125 125
     end
126 126
 
127 127
     def self.setup_worker
128
-      if Agents::TwitterStreamAgent.dependencies_missing?
129
-        STDERR.puts Agents::TwitterStreamAgent.twitter_dependencies_missing
130
-        STDERR.flush
131
-        return false
132
-      end
133
-
134 128
       Agents::TwitterStreamAgent.active.group_by { |agent| agent.twitter_oauth_token }.map do |oauth_token, agents|
129
+        if Agents::TwitterStreamAgent.dependencies_missing?
130
+          STDERR.puts Agents::TwitterStreamAgent.twitter_dependencies_missing
131
+          STDERR.flush
132
+          return false
133
+        end
134
+
135 135
         filter_to_agent_map = agents.map { |agent| agent.options[:filters] }.flatten.uniq.compact.map(&:strip).inject({}) { |m, f| m[f] = []; m }
136 136
 
137 137
         agents.each do |agent|
@@ -176,7 +176,7 @@ module Agents
176 176
 
177 177
       def stop
178 178
         EventMachine.stop_event_loop if EventMachine.reactor_running?
179
-        thread.terminate
179
+        terminate_thread!
180 180
       end
181 181
 
182 182
       private

+ 13 - 13
app/models/agents/weather_agent.rb

@@ -71,8 +71,16 @@ module Agents
71 71
         'expected_update_period_in_days' => '2'
72 72
       }
73 73
     end
74
+    
75
+    def check
76
+      if key_setup?
77
+        create_event :payload => model(weather_provider, which_day).merge('location' => location)
78
+      end
79
+    end
74 80
 
75
-    def service
81
+    private
82
+    
83
+    def weather_provider
76 84
       interpolated["service"].presence || "wunderground"
77 85
     end
78 86
 
@@ -85,8 +93,7 @@ module Agents
85 93
     end
86 94
 
87 95
     def validate_options
88
-      errors.add(:base, "service is required") unless service.present?
89
-      errors.add(:base, "service must be set to 'forecastio' or 'wunderground'") unless ["forecastio", "wunderground"].include?(service)
96
+      errors.add(:base, "service must be set to 'forecastio' or 'wunderground'") unless ["forecastio", "wunderground"].include?(weather_provider)
90 97
       errors.add(:base, "location is required") unless location.present?
91 98
       errors.add(:base, "api_key is required") unless key_setup?
92 99
       errors.add(:base, "which_day selection is required") unless which_day.present?
@@ -104,10 +111,10 @@ module Agents
104 111
       end
105 112
     end
106 113
 
107
-    def model(service,which_day)
108
-      if service == "wunderground"
114
+    def model(weather_provider,which_day)
115
+      if weather_provider == "wunderground"
109 116
         wunderground[which_day]
110
-      elsif service == "forecastio"
117
+      elsif weather_provider == "forecastio"
111 118
         forecastio.each do |value|
112 119
           timestamp = Time.at(value.time)
113 120
           if (timestamp.to_date - Time.now.to_date).to_i == which_day
@@ -174,12 +181,5 @@ module Agents
174 181
         end
175 182
       end
176 183
     end
177
-
178
-    def check
179
-      if key_setup?
180
-        create_event :payload => model(service, which_day).merge('location' => location)
181
-      end
182
-    end
183
-
184 184
   end
185 185
 end

+ 8 - 3
app/models/agents/webhook_agent.rb

@@ -18,11 +18,12 @@ module Agents
18 18
         * `expected_receive_period_in_days` - How often you expect to receive
19 19
           events this way. Used to determine if the agent is working.
20 20
         * `payload_path` - JSONPath of the attribute in the POST body to be
21
-          used as the Event payload.  If `payload_path` points to an array,
22
-          Events will be created for each element.
21
+          used as the Event payload.  Set to `.` to return the entire message.
22
+          If `payload_path` points to an array, Events will be created for each element.
23 23
         * `verbs` - Comma-separated list of http verbs your agent will accept.
24 24
           For example, "post,get" will enable POST and GET requests. Defaults
25 25
           to "post".
26
+        * `response` - The response message to the request. Defaults to 'Event Created'.
26 27
       MD
27 28
     end
28 29
 
@@ -53,7 +54,7 @@ module Agents
53 54
         create_event(payload: payload)
54 55
       end
55 56
 
56
-      ['Event Created', 201]
57
+      [response_message, 201]
57 58
     end
58 59
 
59 60
     def working?
@@ -69,5 +70,9 @@ module Agents
69 70
     def payload_for(params)
70 71
       Utils.value_at(params, interpolated['payload_path']) || {}
71 72
     end
73
+
74
+    def response_message
75
+      interpolated['response'] || 'Event Created'
76
+    end
72 77
   end
73 78
 end

+ 12 - 4
app/models/agents/website_agent.rb

@@ -264,8 +264,9 @@ module Agents
264 264
         error "Ignoring a non-HTTP url: #{url.inspect}"
265 265
         return
266 266
       end
267
-      log "Fetching #{url}"
268
-      response = faraday.get(url)
267
+      uri = Utils.normalize_uri(url)
268
+      log "Fetching #{uri}"
269
+      response = faraday.get(uri)
269 270
       raise "Failed: #{response.inspect}" unless response.success?
270 271
 
271 272
       interpolation_context.stack {
@@ -303,7 +304,7 @@ module Agents
303 304
           interpolated['extract'].keys.each do |name|
304 305
             result[name] = output[name][index]
305 306
             if name.to_s == 'url'
306
-              result[name] = (response.env[:url] + result[name]).to_s
307
+              result[name] = (response.env[:url] + Utils.normalize_uri(result[name])).to_s
307 308
             end
308 309
           end
309 310
 
@@ -439,7 +440,14 @@ module Agents
439 440
         case nodes
440 441
         when Nokogiri::XML::NodeSet
441 442
           result = nodes.map { |node|
442
-            case value = node.xpath(extraction_details['value'] || '.')
443
+            value = node.xpath(extraction_details['value'] || '.')
444
+            if value.is_a?(Nokogiri::XML::NodeSet)
445
+              child = value.first
446
+              if child && child.cdata?
447
+                value = child.text
448
+              end
449
+            end
450
+            case value
443 451
             when Float
444 452
               # Node#xpath() returns any numeric value as float;
445 453
               # convert it to integer as appropriate.

+ 1 - 2
app/views/agents/_form.html.erb

@@ -65,9 +65,8 @@
65 65
               <div class="can-control-other-agents">
66 66
                 <div class="form-group">
67 67
                   <%= f.label :control_targets %>
68
-                  <% eventControlTargets = current_user.agents.select(&:can_be_scheduled?) %>
69 68
                   <%= f.select(:control_target_ids,
70
-                               options_for_select(eventControlTargets.map {|s| [s.name, s.id] },
69
+                               options_for_select(current_user.agents.map {|s| [s.name, s.id] },
71 70
                                                   @agent.control_target_ids),
72 71
                                {}, { multiple: true, size: 5, class: 'select2 form-control' }) %>
73 72
                 </div>

+ 1 - 1
app/views/agents/_table.html.erb

@@ -53,7 +53,7 @@
53 53
         </td>
54 54
         <td class='<%= "agent-unavailable" if agent.unavailable? %>'>
55 55
           <% if agent.can_create_events? %>
56
-            <%= link_to(agent.events_count || 0, agent_events_path(agent)) %>
56
+            <%= link_to(agent.events_count || 0, agent_events_path(agent, return: (defined?(return_to) && return_to) || request.path)) %>
57 57
           <% else %>
58 58
             <span class='not-applicable'></span>
59 59
           <% end %>

+ 1 - 1
app/views/agents/show.html.erb

@@ -15,7 +15,7 @@
15 15
           <li><a href="#logs" data-toggle="tab" data-agent-id="<%= @agent.id %>" class='<%= @agent.recent_error_logs? ? 'recent-errors' : '' %>'><span class='glyphicon glyphicon-list-alt'></span> Logs</a></li>
16 16
 
17 17
           <% if @agent.can_create_events? && @agent.events.count > 0 %>
18
-            <li><%= link_to icon_tag('glyphicon-random') + ' Events'.html_safe, agent_events_path(@agent) %></li>
18
+            <li><%= link_to icon_tag('glyphicon-random') + ' Events'.html_safe, agent_events_path(@agent, return: request.fullpath) %></li>
19 19
           <% else %>
20 20
             <li class='disabled'><a><span class='glyphicon glyphicon-random'></span> Events</a></li>
21 21
           <% end %>

+ 1 - 1
app/views/diagrams/show.html.erb

@@ -20,7 +20,7 @@
20 20
       </div>
21 21
 
22 22
       <div class='digraph'>
23
-        <%= render_agents_diagram(@agents) %>
23
+        <%= render_agents_diagram(@agents, layout: params[:layout]) %>
24 24
       </div>
25 25
     </div>
26 26
   </div>

+ 1 - 1
app/views/events/index.html.erb

@@ -40,7 +40,7 @@
40 40
 
41 41
       <% if @agent %>
42 42
         <div class="btn-group">
43
-          <%= link_to icon_tag('glyphicon-eye-open') + ' View Agent'.html_safe, agent_path(@agent, return: request.fullpath), class: "btn btn-default" %>
43
+          <%= link_to icon_tag('glyphicon-chevron-left') + ' Back'.html_safe, filtered_agent_return_link || agents_path, class: "btn btn-default" %>
44 44
           <%= link_to icon_tag('glyphicon-random') + ' See all events'.html_safe, events_path, class: "btn btn-default" %>
45 45
         </div>
46 46
       <% end %>

+ 1 - 1
config/initializers/delayed_job.rb

@@ -16,4 +16,4 @@ end
16 16
 
17 17
 Delayed::Backend::ActiveRecord.configure do |config|
18 18
   config.reserve_sql_strategy = :default_sql
19
-end
19
+end

+ 10 - 1
config/smtp.yml

@@ -6,6 +6,9 @@ development:
6 6
   enable_starttls_auto: <%= ENV['SMTP_ENABLE_STARTTLS_AUTO'] == 'true' ? true : false %>
7 7
   user_name: <%= ENV['SMTP_USER_NAME'] || "" %>
8 8
   password: <%= ENV['SMTP_PASSWORD'] || "" %>
9
+  openssl_verify_mode: <%= ENV['SMTP_OPENSSL_VERIFY_MODE'].presence || 'null' %>
10
+  ca_path: <%= ENV['SMTP_OPENSSL_CA_PATH'].presence || 'null' %>
11
+  ca_file: <%= ENV['SMTP_OPENSSL_CA_FILE'].presence || 'null' %>
9 12
 
10 13
 staging:
11 14
   address: <%= ENV['SMTP_SERVER'] || "smtp.gmail.com" %>
@@ -15,6 +18,9 @@ staging:
15 18
   enable_starttls_auto: <%= ENV['SMTP_ENABLE_STARTTLS_AUTO'] == 'true' ? true : false %>
16 19
   user_name: <%= ENV['SMTP_USER_NAME'] || "" %>
17 20
   password: <%= ENV['SMTP_PASSWORD'] || "" %>
21
+  openssl_verify_mode: <%= ENV['SMTP_OPENSSL_VERIFY_MODE'].presence || 'null' %>
22
+  ca_path: <%= ENV['SMTP_OPENSSL_CA_PATH'].presence || 'null' %>
23
+  ca_file: <%= ENV['SMTP_OPENSSL_CA_FILE'].presence || 'null' %>
18 24
 
19 25
 production:
20 26
   address: <%= ENV['SMTP_SERVER'] || "smtp.gmail.com" %>
@@ -23,4 +29,7 @@ production:
23 29
   authentication: <%= ENV['SMTP_AUTHENTICATION'] || "plain" %>
24 30
   enable_starttls_auto: <%= ENV['SMTP_ENABLE_STARTTLS_AUTO'] == 'true' ? true : false %>
25 31
   user_name: <%= ENV['SMTP_USER_NAME'] || "" %>
26
-  password: <%= ENV['SMTP_PASSWORD'] || "" %>
32
+  password: <%= ENV['SMTP_PASSWORD'] || "" %>
33
+  openssl_verify_mode: <%= ENV['SMTP_OPENSSL_VERIFY_MODE'].presence || 'null' %>
34
+  ca_path: <%= ENV['SMTP_OPENSSL_CA_PATH'].presence || 'null' %>
35
+  ca_file: <%= ENV['SMTP_OPENSSL_CA_FILE'].presence || 'null' %>

+ 1 - 1
doc/heroku/install.md

@@ -10,7 +10,7 @@ If you still wish to use the Heroku free plan (which won't work very well), plea
10 10
 
11 11
 * Heroku's [free plan](https://www.heroku.com/pricing) limits total runtime per day to 18 hours. This means that Huginn must sleep some of the time, and so recurring tasks will only run if their recurrence frequency fits within the free plan's awake time, which is 30 minutes. Therefore, we recommend that you only use the every 1 minute, every 2 minute, and every 5 minute Agent scheduling options.
12 12
 * If you're using the free plan, you need to signup for a free [uptimerobot](https://uptimerobot.com) account and have it ping your Huginn URL on Heroku once every 70 minutes.  If you still receive warnings from Heroku, try a longer interval.
13
-* Heroku's free Postgres plan limits the number of database rows that you can have to 10,000, so you should be sure to set a low event retention schedule for your agents.
13
+* Heroku's free Postgres plan limits the number of database rows that you can have to 10,000, so you should be sure to set a low event retention schedule for your agents and set `AGENT_LOG_LENGTH`, the number of log lines kept in the DB per Agent, to something small: `heroku config:set AGENT_LOG_LENGTH=20`.
14 14
 
15 15
 ## Instructions
16 16
 

+ 1 - 1
lib/agent_runner.rb

@@ -100,7 +100,7 @@ class AgentRunner
100 100
 
101 101
   def restart_dead_workers
102 102
     @workers.each_pair do |id, worker|
103
-      if worker.thread && !worker.thread.alive?
103
+      if !worker.restarting && worker.thread && !worker.thread.alive?
104 104
         puts "Restarting #{id.to_s}"
105 105
         @workers[id].run!
106 106
       end

+ 1 - 1
lib/huginn_scheduler.rb

@@ -61,7 +61,7 @@ class Rufus::Scheduler
61 61
         job.scheduler_agent_id = agent_id
62 62
 
63 63
         if scheduler_agent = job.scheduler_agent
64
-          scheduler_agent.check!
64
+          scheduler_agent.control!
65 65
         else
66 66
           puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (disabled or deleted)"
67 67
           job.unschedule

+ 3 - 0
lib/json_with_indifferent_access.rb

@@ -1,6 +1,9 @@
1 1
 class JSONWithIndifferentAccess
2 2
   def self.load(json)
3 3
     ActiveSupport::HashWithIndifferentAccess.new(JSON.parse(json || '{}'))
4
+  rescue JSON::ParserError
5
+    Rails.logger.error "Unparsable JSON in JSONWithIndifferentAccess: #{json}"
6
+    { 'error' => 'unparsable json detected during de-serialization' }
4 7
   end
5 8
 
6 9
   def self.dump(hash)

+ 6 - 1
lib/tasks/production.rake

@@ -38,6 +38,11 @@ namespace :production do
38 38
     run_sv('start')
39 39
   end
40 40
 
41
+  task :force_stop => :check do
42
+    puts "Force stopping huginn ..."
43
+    run_sv('force-stop')
44
+  end
45
+
41 46
   task :status => :check do
42 47
     run_sv('status')
43 48
   end
@@ -91,4 +96,4 @@ rescue StandardError => e
91 96
   raise e
92 97
 else
93 98
   puts output
94
-end
99
+end

+ 12 - 0
lib/utils.rb

@@ -21,6 +21,18 @@ module Utils
21 21
     end
22 22
   end
23 23
 
24
+  def self.normalize_uri(uri)
25
+    begin
26
+      URI(uri)
27
+    rescue URI::Error
28
+      URI(uri.to_s.gsub(/[^\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]+/) { |unsafe|
29
+            unsafe.bytes.each_with_object(String.new) { |uc, s|
30
+              s << sprintf('%%%02X', uc)
31
+            }
32
+          }.force_encoding(Encoding::US_ASCII))
33
+    end
34
+  end
35
+
24 36
   def self.interpolate_jsonpaths(value, data, options = {})
25 37
     if options[:leading_dollarsign_is_jsonpath] && value[0] == '$'
26 38
       Utils.values_at(data, value).first.to_s

+ 1 - 1
spec/concerns/dry_runnable_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe DryRunnable do
4 4
   class Agents::SandboxedAgent < Agent

+ 1 - 1
spec/concerns/form_configurable_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe FormConfigurable do
4 4
   class Agent1

+ 1 - 1
spec/concerns/inheritance_tracking_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'inheritance_tracking'
3 3
 
4 4
 describe InheritanceTracking do

+ 1 - 1
spec/concerns/liquid_droppable_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe LiquidDroppable do
4 4
   before do

+ 1 - 1
spec/concerns/liquid_interpolatable_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'nokogiri'
3 3
 
4 4
 describe LiquidInterpolatable::Filters do

+ 9 - 1
spec/concerns/long_runnable_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe LongRunnable do
4 4
   class LongRunnableAgent < Agent
@@ -76,6 +76,14 @@ describe LongRunnable do
76 76
     context "#stop!" do
77 77
       it "terminates the thread" do
78 78
         mock(@worker.thread).terminate
79
+        mock(@worker.thread).status { 'run' }
80
+        @worker.stop!
81
+      end
82
+
83
+      it "wakes up sleeping threads after termination" do
84
+        mock(@worker.thread).terminate
85
+        mock(@worker.thread).status { 'sleep' }
86
+        mock(@worker.thread).wakeup
79 87
         @worker.stop!
80 88
       end
81 89
 

+ 3 - 3
spec/concerns/sortable_events_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe SortableEvents do
4 4
   let(:agent_class) {
@@ -152,7 +152,7 @@ describe SortableEvents do
152 152
             passive_agent_class.class_eval do
153 153
               can_order_created_events!
154 154
             end
155
-          }.to raise_error
155
+          }.to raise_error('Cannot order events for agent that cannot create events')
156 156
         end
157 157
 
158 158
         it 'should work if called from an Agent that can create events' do
@@ -160,7 +160,7 @@ describe SortableEvents do
160 160
             active_agent_class.class_eval do
161 161
               can_order_created_events!
162 162
             end
163
-          }.not_to raise_error
163
+          }.not_to raise_error()
164 164
         end
165 165
       end
166 166
 

+ 1 - 1
spec/controllers/agents_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe AgentsController do
4 4
   def valid_attributes(options = {})

+ 1 - 1
spec/controllers/concerns/sortable_table_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe SortableTable do
4 4
   class SortableTestController

+ 1 - 1
spec/controllers/events_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe EventsController do
4 4
   before do

+ 1 - 1
spec/controllers/jobs_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe JobsController do
4 4
   describe "GET index" do

+ 1 - 1
spec/controllers/logs_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe LogsController do
4 4
   describe "GET index" do

+ 1 - 1
spec/controllers/omniauth_callbacks_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe OmniauthCallbacksController do
4 4
   before do

+ 1 - 1
spec/controllers/scenario_imports_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ScenarioImportsController do
4 4
   before do

+ 1 - 1
spec/controllers/scenarios_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ScenariosController do
4 4
   def valid_attributes(options = {})

+ 1 - 1
spec/controllers/services_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ServicesController do
4 4
   before do

+ 1 - 1
spec/controllers/user_credentials_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe UserCredentialsController do
4 4
   def valid_attributes(options = {})

+ 1 - 1
spec/controllers/web_requests_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe WebRequestsController do
4 4
   class Agents::WebRequestReceiverAgent < Agent

+ 131 - 0
spec/data_fixtures/cdata_rss.atom

@@ -0,0 +1,131 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-gb">
3
+  <link rel="self" type="application/atom+xml" href="http://rainmeter.net/forum/feed.php" />
4
+  <title>Rainmeter Forums</title>
5
+  <link href="http://rainmeter.net/forum/index.php" />
6
+  <updated>2015-10-11T14:52:35+00:00</updated>
7
+  <author>
8
+    <name><![CDATA[Rainmeter Forums]]></name>
9
+  </author>
10
+  <id>http://rainmeter.net/forum/feed.php</id>
11
+  <entry>
12
+    <author>
13
+      <name><![CDATA[supergergo]]></name>
14
+    </author>
15
+    <updated>2015-10-11T14:52:35+00:00</updated>
16
+    <published>2015-10-11T14:52:35+00:00</published>
17
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116390#p116390</id>
18
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116390#p116390" />
19
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Cannot change font for Simple Launcher]]></title>
20
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
21
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116390#p116390"><![CDATA[<div class="quotewrapper"><div class="quotetitle">jsmorley wrote:</div><div class="quotecontent"><br />You don't use the file name of the font, you use the internal font name.<br /><br /><span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">FontFace=ITC Avant Garde Pro XLt</span><br /><br /><div class="attachwrapper">1.jpg</div><br /></div></div><br /><br />Thank you!!<p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=35297">supergergo</a> — 29 minutes ago</p><hr />]]></content>
22
+  </entry>
23
+  <entry>
24
+    <author>
25
+      <name><![CDATA[redsaph]]></name>
26
+    </author>
27
+    <updated>2015-10-11T13:51:44+00:00</updated>
28
+    <published>2015-10-11T13:51:44+00:00</published>
29
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21411&amp;p=116389#p116389</id>
30
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21411&amp;p=116389#p116389" />
31
+    <title type="html"><![CDATA[Plugins &amp; Addons • Re: Plugin: ColorExtract]]></title>
32
+    <category term="Plugins &amp; Addons" scheme="http://rainmeter.net/forum/viewforum.php?f=18" label="Plugins &amp; Addons" />
33
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21411&amp;p=116389#p116389"><![CDATA[This is pretty great! The things we can do with this plugin...  <img src="http://rainmeter.net/forum/images/smilies/yikes.gif" alt=":o" title="Shocked" /><p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=28432">redsaph</a> — Today, 1:51 pm</p><hr />]]></content>
34
+  </entry>
35
+  <entry>
36
+    <author>
37
+      <name><![CDATA[jsmorley]]></name>
38
+    </author>
39
+    <updated>2015-10-11T13:36:51+00:00</updated>
40
+    <published>2015-10-11T13:36:51+00:00</published>
41
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116388#p116388</id>
42
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116388#p116388" />
43
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Cannot change font for Simple Launcher]]></title>
44
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
45
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116388#p116388"><![CDATA[You don't use the file name of the font, you use the internal font name.<br /><br /><span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">FontFace=ITC Avant Garde Pro XLt</span><br /><br /><div class="attachwrapper">1.jpg</div><p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=85">jsmorley</a> — Today, 1:36 pm</p><hr />]]></content>
46
+  </entry>
47
+  <entry>
48
+    <author>
49
+      <name><![CDATA[xlr8r_]]></name>
50
+    </author>
51
+    <updated>2015-10-11T13:35:15+00:00</updated>
52
+    <published>2015-10-11T13:35:15+00:00</published>
53
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21939&amp;p=116387#p116387</id>
54
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21939&amp;p=116387#p116387" />
55
+    <title type="html"><![CDATA[Tips &amp; Tricks • Re: Working with the HWiNFO plugin]]></title>
56
+    <category term="Tips &amp; Tricks" scheme="http://rainmeter.net/forum/viewforum.php?f=15" label="Tips &amp; Tricks" />
57
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21939&amp;p=116387#p116387"><![CDATA[again: one more<p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=35276">xlr8r_</a> — Today, 1:35 pm</p><hr />]]></content>
58
+  </entry>
59
+  <entry>
60
+    <author>
61
+      <name><![CDATA[supergergo]]></name>
62
+    </author>
63
+    <updated>2015-10-11T13:27:56+00:00</updated>
64
+    <published>2015-10-11T13:27:56+00:00</published>
65
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116386#p116386</id>
66
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116386#p116386" />
67
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Cannot change font for Simple Launcher]]></title>
68
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
69
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21984&amp;p=116386#p116386"><![CDATA[Hi,<br />I simply cannot change the font for this skin. I tried the @Resources folder in the skin's root, but nothing, I also tried to install the font, but still, nothing. <br /><br />Here is a snip of my code:<br />[APP1]<br />Meter=STRING<br />X=0<br />Y=0<br />FontColor=255, 255, 255, 1000<br />FontSize=40<br />FontFace=ITCAvantGardePro-XLt<br />SolidColor=0,0,0,1<br />StringStyle=NORMAL<br />StringAlign=LEFT<br />AntiAlias=1<br />Text=&quot;University&quot;<br />LeftMouseUpAction=!Execute [&quot;C:\Users\G\Documents\Uni&quot;]<br /><br />I also tried to use other variations of the font, but still nothing happens when I save and hit refresh. Even if it says the font's name at FontFace, it does not change.<br /><br />Link for the launcher: <!-- m --><a class="postlink" href="http://danieliop.deviantart.com/art/Simple-RM-Launcher-216478809">http://danieliop.deviantart.com/art/Sim ... -216478809</a><!-- m --><p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=35297">supergergo</a> — Today, 1:27 pm</p><hr />]]></content>
70
+  </entry>
71
+  <entry>
72
+    <author>
73
+      <name><![CDATA[balala]]></name>
74
+    </author>
75
+    <updated>2015-10-11T08:27:55+00:00</updated>
76
+    <published>2015-10-11T08:27:55+00:00</published>
77
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116381#p116381</id>
78
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116381#p116381" />
79
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Test if Today is Between 2 Dates]]></title>
80
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
81
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116381#p116381"><![CDATA[If I'm not wrong your bangs are wrong, because you want to show/hide a group of meters. For that, you have to use the !ShowMeterGroup/!HideMeterGroup bangs, instead of !ShowGroup/!HideGroup. The last pair will show/hide a group of active skins, while the !ShowMeterGroup/!HideMeterGroup will show/hide a group of meters. On your posted code, One is a group of meters (it contains the [Boo] meter). So, replace the [MeasureDate] measure with something like this:<br /><div><div class="codetitle"><b>Code:</b> </div><div class="codecontent"><code>&#91;MeasureDate&#93;<br />Measure=Time<br />Format=%#m.%d<br />IfCondition=(MeasureDate &gt;= 12.02) &amp;&amp; (MeasureDate &lt;= 12.25)<br />IfTrueAction=&#91;!ShowMeterGroup One&#93;<br />IfFalseAction=&#91;!HideMeterGroup One&#93;</code></div></div><p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=7491">balala</a> — Today, 8:27 am</p><hr />]]></content>
82
+  </entry>
83
+  <entry>
84
+    <author>
85
+      <name><![CDATA[bill98]]></name>
86
+    </author>
87
+    <updated>2015-10-11T14:02:43+00:00</updated>
88
+    <published>2015-10-11T07:36:57+00:00</published>
89
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116380#p116380</id>
90
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116380#p116380" />
91
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Test if Today is Between 2 Dates]]></title>
92
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
93
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116380#p116380"><![CDATA[Thanks!  I must have something else wrong, because it always shows the group/Image.<br /><br /><div><div class="codetitle"><b>Code:</b> </div><div class="codecontent"><code>&#91;Variables&#93;<br />@Include1=#@#VariablesM2.inc<br />@Include2=#@#FloatingImage.inc<br />IN=1<br />;Holiday<br /><br />&#91;mIName&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1IName#&quot;,&quot;^2$&quot;:&quot;#2IName#&quot;,&quot;^3$&quot;:&quot;#3IName#&quot;,&quot;^4$&quot;:&quot;#4IName#&quot;,&quot;^5$&quot;:&quot;#5IName#&quot;,&quot;^6$&quot;:&quot;#6IName#&quot;,&quot;^7$&quot;:&quot;#7IName#&quot;,&quot;^8$&quot;:&quot;#8IName#&quot;,&quot;^9$&quot;:&quot;#9IName#&quot;,&quot;^10$&quot;:&quot;#10IName#&quot;<br /><br />&#91;mSizex&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1Sizex#&quot;,&quot;^2$&quot;:&quot;#2Sizex#&quot;,&quot;^3$&quot;:&quot;#3Sizex#&quot;,&quot;^4$&quot;:&quot;#4Sizex#&quot;,&quot;^5$&quot;:&quot;#5Sizex#&quot;,&quot;^6$&quot;:&quot;#6Sizex#&quot;,&quot;^7$&quot;:&quot;#7Sizex#&quot;,&quot;^8$&quot;:&quot;#8Sizex#&quot;,&quot;^9$&quot;:&quot;#9Sizex#&quot;,&quot;^10$&quot;:&quot;#10Sizex#&quot;<br /><br />&#91;mSizey&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1Sizey#&quot;,&quot;^2$&quot;:&quot;#2Sizey#&quot;,&quot;^3$&quot;:&quot;#3Sizey#&quot;,&quot;^4$&quot;:&quot;#4Sizey#&quot;,&quot;^5$&quot;:&quot;#5Sizey#&quot;,&quot;^6$&quot;:&quot;#6Sizey#&quot;,&quot;^7$&quot;:&quot;#7Sizey#&quot;,&quot;^8$&quot;:&quot;#8Sizey#&quot;,&quot;^9$&quot;:&quot;#9Sizey#&quot;,&quot;^10$&quot;:&quot;#10Sizey#&quot;<br /><br />&#91;mXMovemt&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1XMovemt#&quot;,&quot;^2$&quot;:&quot;#2XMovemt#&quot;,&quot;^3$&quot;:&quot;#3XMovemt#&quot;,&quot;^4$&quot;:&quot;#4XMovemt#&quot;,&quot;^5$&quot;:&quot;#5XMovemt#&quot;,&quot;^6$&quot;:&quot;#6XMovemt#&quot;,&quot;^7$&quot;:&quot;#7XMovemt#&quot;,&quot;^8$&quot;:&quot;#8XMovemt#&quot;,&quot;^9$&quot;:&quot;#9XMovemt#&quot;,&quot;^10$&quot;:&quot;#10XMovemt#&quot;<br /><br />&#91;mYMovemt&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1YMovemt#&quot;,&quot;^2$&quot;:&quot;#2YMovemt#&quot;,&quot;^3$&quot;:&quot;#3YMovemt#&quot;,&quot;^4$&quot;:&quot;#4YMovemt#&quot;,&quot;^5$&quot;:&quot;#5YMovemt#&quot;,&quot;^6$&quot;:&quot;#6YMovemt#&quot;,&quot;^7$&quot;:&quot;#7YMovemt#&quot;,&quot;^8$&quot;:&quot;#8YMovemt#&quot;,&quot;^9$&quot;:&quot;#9YMovemt#&quot;,&quot;^10$&quot;:&quot;#10YMovemt#&quot;<br /><br />&#91;mUpDDiv&#93;<br />Measure=String<br />String=#IN#<br />DynamicVariables=1<br />RegExpSubstitute=1<br />Substitute=&quot;^1$&quot;:&quot;#1UpDDiv#&quot;,&quot;^2$&quot;:&quot;#2UpDDiv#&quot;,&quot;^3$&quot;:&quot;#3UpDDiv#&quot;,&quot;^4$&quot;:&quot;#4UpDDiv#&quot;,&quot;^5$&quot;:&quot;#5UpDDiv#&quot;,&quot;^6$&quot;:&quot;#6UpDDiv#&quot;,&quot;^7$&quot;:&quot;#7UpDDiv#&quot;,&quot;^8$&quot;:&quot;#8UpDDiv#&quot;,&quot;^9$&quot;:&quot;#9UpDDiv#&quot;,&quot;^10$&quot;:&quot;#10UpDDiv#&quot;<br /><br />&#91;MeasureDate&#93;<br />Measure=Time<br />Format=%#m.%d<br />IfCondition=(MeasureDate &gt;= 12.02) &amp;&amp; (MeasureDate &lt;= 12.25)<br />IfTrueAction=&#91;!ShowGroup One&#93;<br />IfFalseAction=&#91;!HideGroup One&#93;<br /><br /><br />&#91;RX&#93;<br />Measure=Calc<br />Formula=Random<br />UpdateRandom=1<br />LowBound=(#Xc#-(&#91;mSizex&#93;+&#91;mXMovemt&#93;))<br />HighBound=(#Xc#+&#91;mXMovemt&#93;)<br />DynamicVariables=1<br />UpdateDivider=&#91;mUpDDiv&#93;<br /><br /><br />&#91;RY&#93;<br />Measure=Calc<br />Formula=Random<br />UpdateRandom=1<br />LowBound=(#Yc#-(&#91;mSizey&#93;+&#91;mYMovemt&#93;))<br />HighBound=(#Yc#+&#91;mYMovemt&#93;)<br />DynamicVariables=1<br />UpdateDivider=&#91;mUpDDiv&#93;<br /><br />&#91;Boo&#93;<br />Meter=Image<br />ImageName=#@#icons\&#91;mIName&#93;<br />x=&#91;RX&#93;<br />Y=&#91;RY&#93;<br />DynamicVariables=1<br />Group=One<br /><br /><br />&#91;SHData&#93;<br />Meter=String<br />x=900<br />y=50<br />FontSize=15<br />FontColor=0,0,0,255<br />Text=Xc=#Xc##CRLF#Yc=#Yc##CRLF#RX=&#91;RX&#93;#CRLF#RY=&#91;RY&#93;#CRLF#Xmovemt=&#91;mXmovemt&#93;#CRLF#YMovemt=&#91;mYMovemt&#93;#CRLF#UpDDivider=&#91;mUpDDiv&#93;#CRLF#&#91;MeasureDate&#93;</code></div></div><p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=10741">bill98</a> — Today, 7:36 am</p><hr />]]></content>
94
+  </entry>
95
+  <entry>
96
+    <author>
97
+      <name><![CDATA[balala]]></name>
98
+    </author>
99
+    <updated>2015-10-11T05:42:06+00:00</updated>
100
+    <published>2015-10-11T05:42:06+00:00</published>
101
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21980&amp;p=116379#p116379</id>
102
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21980&amp;p=116379#p116379" />
103
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Lano Visualizer - computer can't auto-sleep]]></title>
104
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
105
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21980&amp;p=116379#p116379"><![CDATA[I had the same issue a while ago. I also looked for a fix/solution, but found no one. Finaly I stoped using any visualizer skin. But I also would be interested finding a fix, if it's possible. There is one?<p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=7491">balala</a> — Today, 5:42 am</p><hr />]]></content>
106
+  </entry>
107
+  <entry>
108
+    <author>
109
+      <name><![CDATA[balala]]></name>
110
+    </author>
111
+    <updated>2015-10-11T05:37:02+00:00</updated>
112
+    <published>2015-10-11T05:37:02+00:00</published>
113
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116378#p116378</id>
114
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116378#p116378" />
115
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Re: Test if Today is Between 2 Dates]]></title>
116
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
117
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116378#p116378"><![CDATA[Don't use the IfMatch/IfMatchAction/IfNotMatchAction options, instead try with IfConditions. To do that, you have to properly format the date. Instead of the <span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">Format=%m%d</span>, try this format option: <span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">Format=%m.%d</span>. Now you'll have a date formated as a decimal number (eg for today I have right now 10.11). This way you can use the IfConditions:<br /><div><div class="codetitle"><b>Code:</b> </div><div class="codecontent"><code>&#91;MeasureDate&#93;<br />Measure=Time<br />Format=%#m.%d<br />IfCondition=((MeasureDate&gt;=10.01)&amp;&amp;(MeasureDate&lt;=10.09))<br />IfTrueAction=&#91;!ShowGroup One&#93;<br />IfFalseAction=&#91;!HideGroup One&#93;</code></div></div><br />Two other comments about your code:<br />1. I don't think you'd need the <span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">IfConditionMode=1</span>/<span style="background: none repeat scroll 0 0 #FFFFFF; border: 1px solid #869AA8; color: #2E8B57; display: inline; font-family: Monaco,'Andale Mono','Courier New',Courier,monospace; font-size: 1em; font-style: normal; line-height: 1.3em; padding: 0 3px;">IfMatchMode=1</span> option. Without it, the condition will be checked when the date is changing, with it, on every update cycle. It's useless.<br />2. Even if you'd try to use the IfMatch option, in your code that was wrong: you can't use operators (=, &lt; or &gt;), because we're talking about strings.<p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=7491">balala</a> — Today, 5:37 am</p><hr />]]></content>
118
+  </entry>
119
+  <entry>
120
+    <author>
121
+      <name><![CDATA[bill98]]></name>
122
+    </author>
123
+    <updated>2015-10-11T03:38:51+00:00</updated>
124
+    <published>2015-10-11T03:38:51+00:00</published>
125
+    <id>http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116377#p116377</id>
126
+    <link href="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116377#p116377" />
127
+    <title type="html"><![CDATA[Help: Rainmeter Skins • Test if Today is Between 2 Dates]]></title>
128
+    <category term="Help: Rainmeter Skins" scheme="http://rainmeter.net/forum/viewforum.php?f=5" label="Help: Rainmeter Skins" />
129
+    <content type="html" xml:base="http://rainmeter.net/forum/viewtopic.php?t=21982&amp;p=116377#p116377"><![CDATA[Can I do something like this to test if the date is between two dates<br /><br /><br />[MeasureDate]<br />Measure=Time<br />Format=%m%d<br />IfMatch&gt;=1001 &amp;&amp; &lt;=1009<br />IfMatchAction=[!ShowGroup One]<br />IfNotMatchAction=[!HideGroup One]<br />IfMatchMode=1<p>Statistics: Posted by <a href="http://rainmeter.net/forum/memberlist.php?mode=viewprofile&amp;u=10741">bill98</a> — Today, 3:38 am</p><hr />]]></content>
130
+  </entry>
131
+</feed>

+ 1 - 0
spec/data_fixtures/search_tweets.json

@@ -0,0 +1 @@
1
+{"statuses":[{"metadata":{"result_type":"recent","iso_language_code":"en"},"created_at":"Fri Dec 20 16:52:25 +0000 2013","id":414075829182676992,"id_str":"414075829182676992","text":"@Just_Reboot #FreeBandNames mono surround","source":"\u003ca href=\"http:\/\/www.myplume.com\/\" rel=\"nofollow\"\u003ePlume\u00a0for\u00a0Android\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":414069174856843264,"in_reply_to_status_id_str":"414069174856843264","in_reply_to_user_id":29296581,"in_reply_to_user_id_str":"29296581","in_reply_to_screen_name":"Just_Reboot","user":{"id":546527520,"id_str":"546527520","name":"Phil Empanada","screen_name":"ItsFuckinOhSo","location":"By cacti and sand","description":"Insert cheesy warnings about this account","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":257,"friends_count":347,"listed_count":10,"created_at":"Fri Apr 06 02:15:16 +0000 2012","favourites_count":345,"utc_offset":-25200,"time_zone":"Arizona","geo_enabled":false,"verified":false,"statuses_count":21765,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/414512901680930816\/AcmN-ByT_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/414512901680930816\/AcmN-ByT_normal.jpeg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/546527520\/1371244104","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"default_profile":true,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"FreeBandNames","indices":[13,27]}],"symbols":[],"urls":[],"user_mentions":[{"screen_name":"Just_Reboot","name":"Reboot","id":29296581,"id_str":"29296581","indices":[0,12]}]},"favorited":false,"retweeted":false,"lang":"en"},{"metadata":{"result_type":"recent","iso_language_code":"en"},"created_at":"Fri Dec 20 16:43:32 +0000 2013","id":414073595372265472,"id_str":"414073595372265472","text":"RT @Just_Reboot: The Dick Tonsils #FreeBandNames\u00a0","source":"\u003ca href=\"http:\/\/twitter.com\/download\/android\" rel=\"nofollow\"\u003eTwitter for Android\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":109670150,"id_str":"109670150","name":"Derek Rodriguez","screen_name":"drod2169","location":"Tampa, Florida","description":"Web Developer, College Student, Workout Addict, Taco Fiend. #FBGT","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":3119,"friends_count":714,"listed_count":142,"created_at":"Fri Jan 29 21:29:16 +0000 2010","favourites_count":3728,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":46991,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/3347464379\/1046c92ab1834f1a3c1692ea585a1d69_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/3347464379\/1046c92ab1834f1a3c1692ea585a1d69_normal.jpeg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/109670150\/1368729387","profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"default_profile":true,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"metadata":{"result_type":"recent","iso_language_code":"en"},"created_at":"Fri Dec 20 16:34:40 +0000 2013","id":414071361066532864,"id_str":"414071361066532864","text":"The Dick Tonsils #FreeBandNames\u00a0","source":"\u003ca href=\"http:\/\/www.myplume.com\/\" rel=\"nofollow\"\u003ePlume\u00a0for\u00a0Android\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":29296581,"id_str":"29296581","name":"Reboot","screen_name":"Just_Reboot","location":"NC","description":"G+ http:\/\/t.co\/MyXmhCAtnD\r\n#TeamKang PR, Attempted Writer, IT Tech, Social Media Berserker and Entertainer, Adjectives, Assertions, Disclaimers","url":null,"entities":{"description":{"urls":[{"url":"http:\/\/t.co\/MyXmhCAtnD","expanded_url":"http:\/\/goo.gl\/giVTc","display_url":"goo.gl\/giVTc","indices":[3,25]}]}},"protected":false,"followers_count":2244,"friends_count":435,"listed_count":60,"created_at":"Mon Apr 06 21:21:27 +0000 2009","favourites_count":79,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":27703,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"131516","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/871578268\/5db26751bf732750f5ca1ed4f9f59309.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/871578268\/5db26751bf732750f5ca1ed4f9f59309.png","profile_background_tile":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/378800000480531175\/93e8b5cb95a635ebb81302f5b6044a76_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/378800000480531175\/93e8b5cb95a635ebb81302f5b6044a76_normal.jpeg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/29296581\/1368898490","profile_link_color":"009999","profile_sidebar_border_color":"FFFFFF","profile_sidebar_fill_color":"EFEFEF","profile_text_color":"333333","profile_use_background_image":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":1,"favorite_count":1,"entities":{"hashtags":[{"text":"FreeBandNames","indices":[17,31]}],"symbols":[],"urls":[],"user_mentions":[]},"favorited":false,"retweeted":false,"lang":"en"},"retweet_count":1,"favorite_count":0,"entities":{"hashtags":[{"text":"FreeBandNames","indices":[34,48]}],"symbols":[],"urls":[],"user_mentions":[{"screen_name":"Just_Reboot","name":"Reboot","id":29296581,"id_str":"29296581","indices":[3,15]}]},"favorited":false,"retweeted":false,"lang":"en"},{"metadata":{"result_type":"recent","iso_language_code":"en"},"created_at":"Fri Dec 20 16:34:40 +0000 2013","id":414071361066532864,"id_str":"414071361066532864","text":"The Dick Tonsils #FreeBandNames\u00a0","source":"\u003ca href=\"http:\/\/www.myplume.com\/\" rel=\"nofollow\"\u003ePlume\u00a0for\u00a0Android\u003c\/a\u003e","truncated":false,"in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"user":{"id":29296581,"id_str":"29296581","name":"Reboot","screen_name":"Just_Reboot","location":"NC","description":"G+ http:\/\/t.co\/MyXmhCAtnD\r\n#TeamKang PR, Attempted Writer, IT Tech, Social Media Berserker and Entertainer, Adjectives, Assertions, Disclaimers","url":null,"entities":{"description":{"urls":[{"url":"http:\/\/t.co\/MyXmhCAtnD","expanded_url":"http:\/\/goo.gl\/giVTc","display_url":"goo.gl\/giVTc","indices":[3,25]}]}},"protected":false,"followers_count":2244,"friends_count":435,"listed_count":60,"created_at":"Mon Apr 06 21:21:27 +0000 2009","favourites_count":79,"utc_offset":-18000,"time_zone":"Eastern Time (US & Canada)","geo_enabled":false,"verified":false,"statuses_count":27703,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"131516","profile_background_image_url":"http:\/\/a0.twimg.com\/profile_background_images\/871578268\/5db26751bf732750f5ca1ed4f9f59309.png","profile_background_image_url_https":"https:\/\/si0.twimg.com\/profile_background_images\/871578268\/5db26751bf732750f5ca1ed4f9f59309.png","profile_background_tile":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/378800000480531175\/93e8b5cb95a635ebb81302f5b6044a76_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/378800000480531175\/93e8b5cb95a635ebb81302f5b6044a76_normal.jpeg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/29296581\/1368898490","profile_link_color":"009999","profile_sidebar_border_color":"FFFFFF","profile_sidebar_fill_color":"EFEFEF","profile_text_color":"333333","profile_use_background_image":true,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false},"geo":null,"coordinates":null,"place":null,"contributors":null,"retweet_count":1,"favorite_count":1,"entities":{"hashtags":[{"text":"FreeBandNames","indices":[17,31]}],"symbols":[],"urls":[],"user_mentions":[]},"favorited":false,"retweeted":false,"lang":"en"}],"search_metadata":{"completed_in":0.012,"max_id":414075829182676992,"max_id_str":"414075829182676992","next_results":"?max_id=414071361066532863&q=%23freebandnames&count=3&include_entities=1","query":"%23freebandnames","refresh_url":"?since_id=414075829182676992&q=%23freebandnames&include_entities=1","count":3,"since_id":0,"since_id_str":"0"}}

+ 17 - 0
spec/data_fixtures/urlTest.html

@@ -0,0 +1,17 @@
1
+<html>
2
+    <head>
3
+        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
4
+        <title>test</title>
5
+    </head>
6
+    <body>
7
+        <ul>
8
+            <li><a href="http://google.com">google</a></li>
9
+            <li><a href="https://www.google.ca/search?q=some query">broken</a></li>
10
+            <li><a href="https://www.google.ca/search?q=some%20query">escaped</a></li>
11
+            <li><a href="http://ko.wikipedia.org/wiki/위키백과:대문">unicode url</a></li>
12
+            <li><a href="https://www.google.ca/search?q=위키백과:대문">unicode param</a></li>
13
+            <li><a href="http://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8">percent encoded url</a></li>
14
+            <li><a href="https://www.google.ca/search?q=%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8">percent encoded param</a></li>
15
+        </ul>
16
+    </body>
17
+</html>

+ 21 - 0
spec/fixtures/agents.yml

@@ -111,3 +111,24 @@ jane_basecamp_agent:
111 111
   user: jane
112 112
   service: generic
113 113
   guid: <%= SecureRandom.hex %>
114
+
115
+
116
+bob_data_output_agent:
117
+  type: Agents::DataOutputAgent
118
+  user: bob
119
+  name: RSS Feed 
120
+  guid: <%= SecureRandom.hex %>
121
+  options: <%= {
122
+    expected_receive_period_in_days: 3,
123
+    secrets: ['secret'],
124
+    template: {
125
+      title: 'unchanged',
126
+      description: 'unchanged',
127
+      item: {
128
+        title: 'unchanged',
129
+        description: 'unchanged',
130
+        author: 'unchanged',
131
+        link: 'http://example.com'
132
+        }
133
+      }
134
+    }.to_json.inspect %>

+ 1 - 1
spec/helpers/application_helper_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ApplicationHelper do
4 4
   describe '#icon_tag' do

+ 2 - 2
spec/helpers/dot_helper_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe DotHelper do
4 4
   describe "with example Agents" do
@@ -72,7 +72,7 @@ describe DotHelper do
72 72
       end
73 73
 
74 74
       it "generates a richer DOT script" do
75
-        expect(agents_dot(@agents, true)).to match(%r{
75
+        expect(agents_dot(@agents, rich: true)).to match(%r{
76 76
           \A
77 77
           digraph \x20 "Agent \x20 Event \x20 Flow" \{
78 78
             node \[ [^\]]+ \];

+ 1 - 1
spec/helpers/jobs_helper_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe JobsHelper do
4 4
   let(:job) { Delayed::Job.new }

+ 1 - 1
spec/helpers/markdown_helper_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe MarkdownHelper do
4 4
 

+ 1 - 1
spec/helpers/scenario_helper_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ScenarioHelper do
4 4
   let(:scenario) { users(:bob).scenarios.build(name: 'Scene', tag_fg_color: '#AAAAAA', tag_bg_color: '#000000') }

+ 1 - 1
spec/lib/agent_runner_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe AgentRunner do
4 4
   context "without traps" do

+ 1 - 1
spec/lib/agents_exporter_spec.rb

@@ -1,6 +1,6 @@
1 1
 # encoding: utf-8
2 2
 
3
-require 'spec_helper'
3
+require 'rails_helper'
4 4
 
5 5
 describe AgentsExporter do
6 6
   describe "#as_json" do

+ 1 - 1
spec/lib/delayed_job_worker_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe DelayedJobWorker do
4 4
   before do

+ 1 - 1
spec/lib/huginn_scheduler_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'huginn_scheduler'
3 3
 
4 4
 describe HuginnScheduler do

+ 1 - 1
spec/lib/liquid_migrator_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe LiquidMigrator do
4 4
   describe "converting JSONPath strings" do

+ 3 - 3
spec/lib/location_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Location do
4 4
   let(:location) {
@@ -30,14 +30,14 @@ describe Location do
30 30
     expect(location['lat']).to eq 2.0
31 31
   end
32 32
 
33
-  it "has a convencience accessor for combined latitude and longitude" do
33
+  it "has a convenience accessor for combined latitude and longitude" do
34 34
     expect(location.latlng).to eq "2.0,3.0"
35 35
   end
36 36
 
37 37
   it "does not allow hash-style assignment" do
38 38
     expect {
39 39
       location[:lat] = 2.0
40
-    }.to raise_error
40
+    }.to raise_error(NoMethodError)
41 41
   end
42 42
 
43 43
   it "ignores invalid values" do

+ 1 - 1
spec/lib/utils_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Utils do
4 4
   describe "#unindent" do

+ 1 - 1
spec/models/agent_log_spec.rb

@@ -1,5 +1,5 @@
1 1
 # -*- coding: utf-8 -*-
2
-require 'spec_helper'
2
+require 'rails_helper'
3 3
 
4 4
 describe AgentLog do
5 5
   describe "validations" do

+ 3 - 3
spec/models/agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agent do
4 4
   it_behaves_like WorkingHelpers
@@ -223,7 +223,7 @@ describe Agent do
223 223
         mock(Agent).find(@checker.id) { @checker }
224 224
         expect {
225 225
           Agents::SomethingSource.async_check(@checker.id)
226
-        }.to raise_error
226
+        }.to raise_error(RuntimeError)
227 227
         log = @checker.logs.first
228 228
         expect(log.message).to match(/Exception/)
229 229
         expect(log.level).to eq(4)
@@ -263,7 +263,7 @@ describe Agent do
263 263
         Agent.async_check(agents(:bob_weather_agent).id)
264 264
         expect {
265 265
           Agent.async_receive(agents(:bob_rain_notifier_agent).id, [agents(:bob_weather_agent).events.last.id])
266
-        }.to raise_error
266
+        }.to raise_error(RuntimeError)
267 267
         log = agents(:bob_rain_notifier_agent).logs.first
268 268
         expect(log.message).to match(/Exception/)
269 269
         expect(log.level).to eq(4)

+ 1 - 1
spec/models/agents/adioso_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::AdiosoAgent do
4 4
 	before do

+ 1 - 1
spec/models/agents/basecamp_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'models/concerns/oauthable'
3 3
 
4 4
 describe Agents::BasecampAgent do

+ 145 - 0
spec/models/agents/beeper_agent_spec.rb

@@ -0,0 +1,145 @@
1
+require 'rails_helper'
2
+
3
+
4
+describe Agents::BeeperAgent do
5
+  let(:base_params) {
6
+    {
7
+      'type'      => 'message',
8
+      'app_id'    => 'some-app-id',
9
+      'api_key'   => 'some-api-key',
10
+      'sender_id' => 'sender-id',
11
+      'phone'     => '+111111111111',
12
+      'text'      => 'Some text'
13
+    }
14
+  }
15
+
16
+  subject {
17
+    agent = described_class.new(name: 'beeper-agent', options: base_params)
18
+    agent.user = users(:jane)
19
+    agent.save! and return agent
20
+  }
21
+
22
+  context 'validation' do
23
+    it 'valid' do
24
+      expect(subject).to be_valid
25
+    end
26
+
27
+    [:type, :app_id, :api_key, :sender_id].each do |attr|
28
+      it "invalid without #{attr}" do
29
+        subject.options[attr] = nil
30
+        expect(subject).not_to be_valid
31
+      end
32
+    end
33
+
34
+    it 'invalid with group_id and phone' do
35
+      subject.options['group_id'] ='some-group-id'
36
+      expect(subject).not_to be_valid
37
+    end
38
+
39
+    context '#message' do
40
+      it 'requires text' do
41
+        subject.options[:text] = nil
42
+        expect(subject).not_to be_valid
43
+      end
44
+    end
45
+
46
+    context '#image' do
47
+      before(:each) do
48
+        subject.options[:type] = 'image'
49
+      end
50
+
51
+      it 'invalid without image' do
52
+        expect(subject).not_to be_valid
53
+      end
54
+
55
+      it 'valid with image' do
56
+        subject.options[:image] = 'some-url'
57
+        expect(subject).to be_valid
58
+      end
59
+    end
60
+
61
+    context '#event' do
62
+      before(:each) do
63
+        subject.options[:type] = 'event'
64
+      end
65
+
66
+      it 'invalid without start_time' do
67
+        expect(subject).not_to be_valid
68
+      end
69
+
70
+      it 'valid with start_time' do
71
+        subject.options[:start_time] = Time.now
72
+        expect(subject).to be_valid
73
+      end
74
+    end
75
+
76
+    context '#location' do
77
+      before(:each) do
78
+        subject.options[:type] = 'location'
79
+      end
80
+
81
+      it 'invalid without latitude and longitude' do
82
+        expect(subject).not_to be_valid
83
+      end
84
+
85
+      it 'valid with latitude and longitude' do
86
+        subject.options[:latitude] = 15.0
87
+        subject.options[:longitude] = 16.0
88
+        expect(subject).to be_valid
89
+      end
90
+    end
91
+
92
+    context '#task' do
93
+      before(:each) do
94
+        subject.options[:type] = 'task'
95
+      end
96
+
97
+      it 'valid with text' do
98
+        expect(subject).to be_valid
99
+      end
100
+    end
101
+  end
102
+
103
+  context 'payload_for' do
104
+    it 'removes unwanted attributes' do
105
+      result = subject.send(:payload_for, {'type' => 'message', 'text' => 'text',
106
+        'sender_id' => 'sender', 'phone' => '+1', 'random_attribute' => 'unwanted'})
107
+      expect(result).to eq('{"text":"text","sender_id":"sender","phone":"+1"}')
108
+    end
109
+  end
110
+
111
+  context 'headers' do
112
+    it 'sets X-Beeper-Application-Id header with app_id' do
113
+      expect(subject.send(:headers)['X-Beeper-Application-Id']).to eq(base_params['app_id'])
114
+    end
115
+
116
+    it 'sets X-Beeper-REST-API-Key header with api_key' do
117
+      expect(subject.send(:headers)['X-Beeper-REST-API-Key']).to eq(base_params['api_key'])
118
+    end
119
+
120
+    it 'sets Content-Type' do
121
+      expect(subject.send(:headers)['Content-Type']).to eq('application/json')
122
+    end
123
+  end
124
+
125
+  context 'endpoint_for' do
126
+    it 'returns valid URL for message' do
127
+      expect(subject.send(:endpoint_for, 'message')).to eq('https://api.beeper.io/api/messages.json')
128
+    end
129
+
130
+    it 'returns valid URL for image' do
131
+      expect(subject.send(:endpoint_for, 'image')).to eq('https://api.beeper.io/api/images.json')
132
+    end
133
+
134
+    it 'returns valid URL for event' do
135
+      expect(subject.send(:endpoint_for, 'event')).to eq('https://api.beeper.io/api/events.json')
136
+    end
137
+
138
+    it 'returns valid URL for location' do
139
+      expect(subject.send(:endpoint_for, 'location')).to eq('https://api.beeper.io/api/locations.json')
140
+    end
141
+    it 'returns valid URL for task' do
142
+      expect(subject.send(:endpoint_for, 'task')).to eq('https://api.beeper.io/api/tasks.json')
143
+    end
144
+  end
145
+end

+ 1 - 1
spec/models/agents/change_detector_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::ChangeDetectorAgent do
4 4
   def create_event(output=nil)

+ 3 - 3
spec/models/agents/commander_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::CommanderAgent do
4 4
   let(:valid_params) {
@@ -19,10 +19,10 @@ describe Agents::CommanderAgent do
19 19
 
20 20
   it_behaves_like AgentControllerConcern
21 21
 
22
-  describe "check!" do
22
+  describe "check" do
23 23
     it "should command targets" do
24 24
       stub(agent).control!.once { nil }
25
-      agent.check!
25
+      agent.check
26 26
     end
27 27
   end
28 28
 

+ 36 - 3
spec/models/agents/data_output_agent_spec.rb

@@ -1,6 +1,6 @@
1 1
 # encoding: utf-8
2 2
 
3
-require 'spec_helper'
3
+require 'rails_helper'
4 4
 
5 5
 describe Agents::DataOutputAgent do
6 6
   let(:agent) do
@@ -73,6 +73,29 @@ describe Agents::DataOutputAgent do
73 73
     end
74 74
   end
75 75
 
76
+  describe "#receive" do
77
+    it "should push to hubs when push_hubs is given" do
78
+      agent.options[:push_hubs] = %w[http://push.example.com]
79
+      agent.options[:template] = { 'link' => 'http://huginn.example.org' }
80
+
81
+      alist = nil
82
+
83
+      stub_request(:post, 'http://push.example.com/')
84
+        .with(headers: { 'Content-Type' => %r{\Aapplication/x-www-form-urlencoded\s*(?:;|\z)} })
85
+        .to_return { |request|
86
+        alist = URI.decode_www_form(request.body).sort
87
+        { status: 200, body: 'ok' }
88
+      }
89
+
90
+      agent.receive(events(:bob_website_agent_event))
91
+
92
+      expect(alist).to eq [
93
+        ["hub.mode", "publish"],
94
+        ["hub.url", agent.feed_url(secret: agent.options[:secrets].first, format: :xml)]
95
+      ]
96
+    end
97
+  end
98
+
76 99
   describe "#receive_web_request" do
77 100
     before do
78 101
       current_time = Time.now
@@ -130,7 +153,7 @@ describe Agents::DataOutputAgent do
130 153
         expect(content_type).to eq('text/xml')
131 154
         expect(content.gsub(/\s+/, '')).to eq Utils.unindent(<<-XML).gsub(/\s+/, '')
132 155
           <?xml version="1.0" encoding="UTF-8" ?>
133
-          <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
156
+          <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
134 157
           <channel>
135 158
            <atom:link href="https://yoursite.com/users/#{agent.user.id}/web_requests/#{agent.id}/secret1.xml" rel="self" type="application/rss+xml"/>
136 159
            <atom:icon>https://yoursite.com/favicon.ico</atom:icon>
@@ -170,6 +193,16 @@ describe Agents::DataOutputAgent do
170 193
         XML
171 194
       end
172 195
 
196
+      it "can output RSS with hub links when push_hubs is specified" do
197
+        stub(agent).feed_link { "https://yoursite.com" }
198
+        agent.options[:push_hubs] = %w[https://pubsubhubbub.superfeedr.com/ https://pubsubhubbub.appspot.com/]
199
+        content, status, content_type = agent.receive_web_request({ 'secret' => 'secret1' }, 'get', 'text/xml')
200
+        expect(status).to eq(200)
201
+        expect(content_type).to eq('text/xml')
202
+        xml = Nokogiri::XML(content)
203
+        expect(xml.xpath('/rss/channel/atom:link[@rel="hub"]/@href').map(&:text).sort).to eq agent.options[:push_hubs].sort
204
+      end
205
+
173 206
       it "can output JSON" do
174 207
         agent.options['template']['item']['foo'] = "hi"
175 208
 
@@ -359,7 +392,7 @@ describe Agents::DataOutputAgent do
359 392
         expect(content_type).to eq('text/xml')
360 393
         expect(content.gsub(/\s+/, '')).to eq Utils.unindent(<<-XML).gsub(/\s+/, '')
361 394
           <?xml version="1.0" encoding="UTF-8" ?>
362
-          <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
395
+          <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
363 396
           <channel>
364 397
            <atom:link href="https://yoursite.com/users/#{agent.user.id}/web_requests/#{agent.id}/secret1.xml" rel="self" type="application/rss+xml"/>
365 398
            <atom:icon>https://yoursite.com/favicon.ico</atom:icon>

+ 1 - 1
spec/models/agents/de_duplication_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::DeDuplicationAgent do
4 4
   def create_event(output=nil)

+ 127 - 0
spec/models/agents/delay_agent_spec.rb

@@ -0,0 +1,127 @@
1
+require 'rails_helper'
2
+
3
+describe Agents::DelayAgent do
4
+  let(:agent) do
5
+    _agent = Agents::DelayAgent.new(name: 'My DelayAgent')
6
+    _agent.options = _agent.default_options.merge('max_events' => 2)
7
+    _agent.user = users(:bob)
8
+    _agent.sources << agents(:bob_website_agent)
9
+    _agent.save!
10
+    _agent
11
+  end
12
+
13
+  def create_event
14
+    _event = Event.new(payload: { random: rand })
15
+    _event.agent = agents(:bob_website_agent)
16
+    _event.save!
17
+    _event
18
+  end
19
+
20
+  let(:first_event) { create_event }
21
+  let(:second_event) { create_event }
22
+  let(:third_event) { create_event }
23
+
24
+  describe "#working?" do
25
+    it "checks if events have been received within expected receive period" do
26
+      expect(agent).not_to be_working
27
+      Agents::DelayAgent.async_receive agent.id, [events(:bob_website_agent_event).id]
28
+      expect(agent.reload).to be_working
29
+      the_future = (agent.options[:expected_receive_period_in_days].to_i + 1).days.from_now
30
+      stub(Time).now { the_future }
31
+      expect(agent.reload).not_to be_working
32
+    end
33
+  end
34
+
35
+  describe "validation" do
36
+    before do
37
+      expect(agent).to be_valid
38
+    end
39
+
40
+    it "should validate max_events" do
41
+      agent.options.delete('max_events')
42
+      expect(agent).not_to be_valid
43
+      agent.options['max_events'] = ""
44
+      expect(agent).not_to be_valid
45
+      agent.options['max_events'] = "0"
46
+      expect(agent).not_to be_valid
47
+      agent.options['max_events'] = "10"
48
+      expect(agent).to be_valid
49
+    end
50
+
51
+    it "should validate presence of expected_receive_period_in_days" do
52
+      agent.options['expected_receive_period_in_days'] = ""
53
+      expect(agent).not_to be_valid
54
+      agent.options['expected_receive_period_in_days'] = 0
55
+      expect(agent).not_to be_valid
56
+      agent.options['expected_receive_period_in_days'] = -1
57
+      expect(agent).not_to be_valid
58
+    end
59
+
60
+    it "should validate keep" do
61
+      agent.options.delete('keep')
62
+      expect(agent).not_to be_valid
63
+      agent.options['keep'] = ""
64
+      expect(agent).not_to be_valid
65
+      agent.options['keep'] = 'wrong'
66
+      expect(agent).not_to be_valid
67
+      agent.options['keep'] = 'newest'
68
+      expect(agent).to be_valid
69
+      agent.options['keep'] = 'oldest'
70
+      expect(agent).to be_valid
71
+    end
72
+  end
73
+
74
+  describe "#receive" do
75
+    it "records Events" do
76
+      expect(agent.memory).to be_empty
77
+      agent.receive([first_event])
78
+      expect(agent.memory).not_to be_empty
79
+      agent.receive([second_event])
80
+      expect(agent.memory['event_ids']).to eq [first_event.id, second_event.id]
81
+    end
82
+
83
+    it "keeps the newest when 'keep' is set to 'newest'" do
84
+      expect(agent.options['keep']).to eq 'newest'
85
+      agent.receive([first_event, second_event, third_event])
86
+      expect(agent.memory['event_ids']).to eq [second_event.id, third_event.id]
87
+    end
88
+
89
+    it "keeps the oldest when 'keep' is set to 'oldest'" do
90
+      agent.options['keep'] = 'oldest'
91
+      agent.receive([first_event, second_event, third_event])
92
+      expect(agent.memory['event_ids']).to eq [first_event.id, second_event.id]
93
+    end
94
+  end
95
+
96
+  describe "#check" do
97
+    it "re-emits Events and clears the memory" do
98
+      agent.receive([first_event, second_event, third_event])
99
+      expect(agent.memory['event_ids']).to eq [second_event.id, third_event.id]
100
+
101
+      expect {
102
+        agent.check
103
+      }.to change { agent.events.count }.by(2)
104
+
105
+      events = agent.events.reorder('events.id desc')
106
+      expect(events.first.payload).to eq third_event.payload
107
+      expect(events.second.payload).to eq second_event.payload
108
+
109
+      expect(agent.memory['event_ids']).to eq []
110
+    end
111
+
112
+    it "re-emits max_emitted_events and clears just them from the memory" do
113
+      agent.options['max_emitted_events'] = 1
114
+      agent.receive([first_event, second_event, third_event])
115
+      expect(agent.memory['event_ids']).to eq [second_event.id, third_event.id]
116
+
117
+      expect {
118
+        agent.check
119
+      }.to change { agent.events.count }.by(1)
120
+
121
+      events = agent.events.reorder('events.id desc')
122
+      expect(agent.memory['event_ids']).to eq [third_event.id]
123
+      expect(events.first.payload).to eq second_event.payload
124
+
125
+    end
126
+  end
127
+end

+ 1 - 1
spec/models/agents/dropbox_file_url_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::DropboxFileUrlAgent do
4 4
   before(:each) do

+ 1 - 1
spec/models/agents/dropbox_watch_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::DropboxWatchAgent do
4 4
   before(:each) do

+ 1 - 1
spec/models/agents/email_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::EmailAgent do
4 4
   it_behaves_like EmailConcern

+ 1 - 1
spec/models/agents/email_digest_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::EmailDigestAgent do
4 4
   it_behaves_like EmailConcern

+ 1 - 1
spec/models/agents/event_formatting_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::EventFormattingAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/evernote_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::EvernoteAgent do
4 4
   class FakeEvernoteNoteStore

+ 2 - 2
spec/models/agents/ftpsite_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'time'
3 3
 
4 4
 describe Agents::FtpsiteAgent do
@@ -26,7 +26,7 @@ describe Agents::FtpsiteAgent do
26 26
 
27 27
       it "should validate the integer fields" do
28 28
         @checker.options['expected_update_period_in_days'] = "nonsense"
29
-        expect { @checker.save! }.to raise_error;
29
+        expect { @checker.save! }.to raise_error(/Invalid expected_update_period_in_days format/);
30 30
         @checker.options = @site
31 31
       end
32 32
 

+ 112 - 0
spec/models/agents/gap_detector_agent_spec.rb

@@ -0,0 +1,112 @@
1
+require 'rails_helper'
2
+
3
+describe Agents::GapDetectorAgent do
4
+  let(:valid_params) {
5
+    {
6
+      'name' => "my gap detector agent",
7
+      'options' => {
8
+        'window_duration_in_days' => "2",
9
+        'message' => "A gap was found!"
10
+      }
11
+    }
12
+  }
13
+
14
+  let(:agent) {
15
+    _agent = Agents::GapDetectorAgent.new(valid_params)
16
+    _agent.user = users(:bob)
17
+    _agent.save!
18
+    _agent
19
+  }
20
+
21
+  describe 'validation' do
22
+    before do
23
+      expect(agent).to be_valid
24
+    end
25
+
26
+    it 'should validate presence of message' do
27
+      agent.options['message'] = nil
28
+      expect(agent).not_to be_valid
29
+    end
30
+
31
+    it 'should validate presence of window_duration_in_days' do
32
+      agent.options['window_duration_in_days'] = ""
33
+      expect(agent).not_to be_valid
34
+
35
+      agent.options['window_duration_in_days'] = "wrong"
36
+      expect(agent).not_to be_valid
37
+
38
+      agent.options['window_duration_in_days'] = "1"
39
+      expect(agent).to be_valid
40
+
41
+      agent.options['window_duration_in_days'] = "0.5"
42
+      expect(agent).to be_valid
43
+    end
44
+  end
45
+
46
+  describe '#receive' do
47
+    it 'records the event if it has a created_at newer than the last seen' do
48
+      agent.receive([events(:bob_website_agent_event)])
49
+      expect(agent.memory['newest_event_created_at']).to eq events(:bob_website_agent_event).created_at.to_i
50
+
51
+      events(:bob_website_agent_event).created_at = 2.days.ago
52
+
53
+      expect {
54
+        agent.receive([events(:bob_website_agent_event)])
55
+      }.to_not change { agent.memory['newest_event_created_at'] }
56
+
57
+      events(:bob_website_agent_event).created_at = 2.days.from_now
58
+
59
+      expect {
60
+        agent.receive([events(:bob_website_agent_event)])
61
+      }.to change { agent.memory['newest_event_created_at'] }.to(events(:bob_website_agent_event).created_at.to_i)
62
+    end
63
+
64
+    it 'ignores the event if value_path is present and the value at the path is blank' do
65
+      agent.options['value_path'] = 'title'
66
+      agent.receive([events(:bob_website_agent_event)])
67
+      expect(agent.memory['newest_event_created_at']).to eq events(:bob_website_agent_event).created_at.to_i
68
+
69
+      events(:bob_website_agent_event).created_at = 2.days.from_now
70
+      events(:bob_website_agent_event).payload['title'] = ''
71
+
72
+      expect {
73
+        agent.receive([events(:bob_website_agent_event)])
74
+      }.to_not change { agent.memory['newest_event_created_at'] }
75
+
76
+      events(:bob_website_agent_event).payload['title'] = 'present!'
77
+
78
+      expect {
79
+        agent.receive([events(:bob_website_agent_event)])
80
+      }.to change { agent.memory['newest_event_created_at'] }.to(events(:bob_website_agent_event).created_at.to_i)
81
+    end
82
+
83
+    it 'clears any previous alert' do
84
+      agent.memory['alerted_at'] = 2.days.ago.to_i
85
+      agent.receive([events(:bob_website_agent_event)])
86
+      expect(agent.memory).to_not have_key('alerted_at')
87
+    end
88
+  end
89
+
90
+  describe '#check' do
91
+    it 'alerts once if no data has been received during window_duration_in_days' do
92
+      agent.memory['newest_event_created_at'] = 1.days.ago.to_i
93
+
94
+      expect {
95
+        agent.check
96
+      }.to_not change { agent.events.count }
97
+
98
+      agent.memory['newest_event_created_at'] = 3.days.ago.to_i
99
+
100
+      expect {
101
+        agent.check
102
+      }.to change { agent.events.count }.by(1)
103
+
104
+      expect(agent.events.last.payload).to eq ({ 'message' => 'A gap was found!',
105
+                                                 'gap_started_at' => agent.memory['newest_event_created_at'] })
106
+
107
+      expect {
108
+        agent.check
109
+      }.not_to change { agent.events.count }
110
+    end
111
+  end
112
+end

+ 1 - 1
spec/models/agents/google_calendar_publish_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::GoogleCalendarPublishAgent, :vcr do
4 4
   before do

+ 1 - 1
spec/models/agents/growl_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::GrowlAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/hipchat_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::HipchatAgent do
4 4
   before(:each) do

+ 1 - 1
spec/models/agents/human_task_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::HumanTaskAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/imap_folder_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'time'
3 3
 
4 4
 describe Agents::ImapFolderAgent do

+ 1 - 1
spec/models/agents/jabber_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::JabberAgent do
4 4
   let(:sent) { [] }

+ 1 - 1
spec/models/agents/java_script_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::JavaScriptAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/jira_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::JiraAgent do
4 4
   before(:each) do

+ 1 - 1
spec/models/agents/mqtt_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'mqtt'
3 3
 require './spec/support/fake_mqtt_server'
4 4
 

+ 1 - 1
spec/models/agents/pdf_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::PdfInfoAgent do
4 4
   let(:agent) do

+ 1 - 1
spec/models/agents/peak_detector_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::PeakDetectorAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/post_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'ostruct'
3 3
 
4 4
 describe Agents::PostAgent do

+ 1 - 1
spec/models/agents/public_transport_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 describe Agents::PublicTransportAgent do
3 3
   before do
4 4
     valid_params = {

+ 1 - 1
spec/models/agents/pushbullet_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::PushbulletAgent do
4 4
   before(:each) do

+ 1 - 1
spec/models/agents/pushover_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::PushoverAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/rss_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::RssAgent do
4 4
   before do

+ 1 - 8
spec/models/agents/scheduler_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::SchedulerAgent do
4 4
   let(:valid_params) {
@@ -84,11 +84,4 @@ describe Agents::SchedulerAgent do
84 84
       expect(agent.memory['scheduled_at']).to be_nil
85 85
     end
86 86
   end
87
-
88
-  describe "check!" do
89
-    it "should control targets" do
90
-      stub(agent).control!.once { nil }
91
-      agent.check!
92
-    end
93
-  end
94 87
 end

+ 1 - 1
spec/models/agents/sentiment_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::SentimentAgent do
4 4
     before do

+ 68 - 10
spec/models/agents/shell_command_agent_spec.rb

@@ -1,23 +1,35 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::ShellCommandAgent do
4 4
   before do
5 5
     @valid_path = Dir.pwd
6 6
 
7 7
     @valid_params = {
8
-        :path  => @valid_path,
9
-        :command  => "pwd",
10
-        :expected_update_period_in_days => "1",
11
-      }
8
+      path: @valid_path,
9
+      command: 'pwd',
10
+      expected_update_period_in_days: '1',
11
+    }
12
+
13
+    @valid_params2 = {
14
+      path: @valid_path,
15
+      command: [RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'],
16
+      stdin: "{{name}}",
17
+      expected_update_period_in_days: '1',
18
+    }
12 19
 
13
-    @checker = Agents::ShellCommandAgent.new(:name => "somename", :options => @valid_params)
20
+    @checker = Agents::ShellCommandAgent.new(name: 'somename', options: @valid_params)
14 21
     @checker.user = users(:jane)
15 22
     @checker.save!
16 23
 
24
+    @checker2 = Agents::ShellCommandAgent.new(name: 'somename2', options: @valid_params2)
25
+    @checker2.user = users(:jane)
26
+    @checker2.save!
27
+
17 28
     @event = Event.new
18 29
     @event.agent = agents(:jane_weather_agent)
19 30
     @event.payload = {
20
-      :cmd => "ls"
31
+      'name' => 'Huginn',
32
+      'cmd' => 'ls',
21 33
     }
22 34
     @event.save!
23 35
 
@@ -27,6 +39,7 @@ describe Agents::ShellCommandAgent do
27 39
   describe "validation" do
28 40
     before do
29 41
       expect(@checker).to be_valid
42
+      expect(@checker2).to be_valid
30 43
     end
31 44
 
32 45
     it "should validate presence of necessary fields" do
@@ -47,7 +60,7 @@ describe Agents::ShellCommandAgent do
47 60
 
48 61
   describe "#working?" do
49 62
     it "generating events as scheduled" do
50
-      stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] }
63
+      stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] }
51 64
 
52 65
       expect(@checker).not_to be_working
53 66
       @checker.check
@@ -60,7 +73,9 @@ describe Agents::ShellCommandAgent do
60 73
 
61 74
   describe "#check" do
62 75
     before do
63
-      stub(@checker).run_command(@valid_path, 'pwd') { ["fake pwd output", "", 0] }
76
+      stub(@checker).run_command(@valid_path, 'pwd', nil) { ["fake pwd output", "", 0] }
77
+      stub(@checker).run_command(@valid_path, 'empty_output', nil) { ["", "", 0] }
78
+      stub(@checker).run_command(@valid_path, 'failure', nil) { ["failed", "error message", 1] }
64 79
     end
65 80
 
66 81
     it "should create an event when checking" do
@@ -70,6 +85,42 @@ describe Agents::ShellCommandAgent do
70 85
       expect(Event.last.payload[:output]).to eq("fake pwd output")
71 86
     end
72 87
 
88
+    it "should create an event when checking (unstubbed)" do
89
+      expect { @checker2.check }.to change { Event.count }.by(1)
90
+      expect(Event.last.payload[:path]).to eq(@valid_path)
91
+      expect(Event.last.payload[:command]).to eq([RbConfig.ruby, '-e', 'puts "hello, #{STDIN.eof? ? "world" : STDIN.read.strip}."; STDERR.puts "warning!"'])
92
+      expect(Event.last.payload[:output]).to eq('hello, world.')
93
+      expect(Event.last.payload[:errors]).to eq('warning!')
94
+    end
95
+
96
+    describe "with suppress_on_empty_output" do
97
+      it "should suppress events on empty output" do
98
+        @checker.options[:suppress_on_empty_output] = true
99
+        @checker.options[:command] = 'empty_output'
100
+        expect { @checker.check }.not_to change { Event.count }
101
+      end
102
+
103
+      it "should not suppress events on non-empty output" do
104
+        @checker.options[:suppress_on_empty_output] = true
105
+        @checker.options[:command] = 'failure'
106
+        expect { @checker.check }.to change { Event.count }.by(1)
107
+      end
108
+    end
109
+
110
+    describe "with suppress_on_failure" do
111
+      it "should suppress events on failure" do
112
+        @checker.options[:suppress_on_failure] = true
113
+        @checker.options[:command] = 'failure'
114
+        expect { @checker.check }.not_to change { Event.count }
115
+      end
116
+
117
+      it "should not suppress events on success" do
118
+        @checker.options[:suppress_on_failure] = true
119
+        @checker.options[:command] = 'empty_output'
120
+        expect { @checker.check }.to change { Event.count }.by(1)
121
+      end
122
+    end
123
+
73 124
     it "does not run when should_run? is false" do
74 125
       stub(Agents::ShellCommandAgent).should_run? { false }
75 126
       expect { @checker.check }.not_to change { Event.count }
@@ -78,7 +129,7 @@ describe Agents::ShellCommandAgent do
78 129
 
79 130
   describe "#receive" do
80 131
     before do
81
-      stub(@checker).run_command(@valid_path, @event.payload[:cmd]) { ["fake ls output", "", 0] }
132
+      stub(@checker).run_command(@valid_path, @event.payload[:cmd], nil) { ["fake ls output", "", 0] }
82 133
     end
83 134
 
84 135
     it "creates events" do
@@ -89,6 +140,13 @@ describe Agents::ShellCommandAgent do
89 140
       expect(Event.last.payload[:output]).to eq("fake ls output")
90 141
     end
91 142
 
143
+    it "creates events (unstubbed)" do
144
+      @checker2.receive([@event])
145
+      expect(Event.last.payload[:path]).to eq(@valid_path)
146
+      expect(Event.last.payload[:output]).to eq('hello, Huginn.')
147
+      expect(Event.last.payload[:errors]).to eq('warning!')
148
+    end
149
+
92 150
     it "does not run when should_run? is false" do
93 151
       stub(Agents::ShellCommandAgent).should_run? { false }
94 152
 

+ 16 - 3
spec/models/agents/slack_agent_spec.rb

@@ -1,12 +1,15 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::SlackAgent do
4 4
   before(:each) do
5
+    @fallback = "Its going to rain"
6
+    @attachments = [{'fallback' => "{{fallback}}"}]
5 7
     @valid_params = {
6 8
                       'webhook_url' => 'https://hooks.slack.com/services/random1/random2/token',
7 9
                       'channel' => '#random',
8 10
                       'username' => "{{username}}",
9
-                      'message' => "{{message}}"
11
+                      'message' => "{{message}}",
12
+                      'attachments' => @attachments
10 13
                     }
11 14
 
12 15
     @checker = Agents::SlackAgent.new(:name => "slacker", :options => @valid_params)
@@ -15,7 +18,7 @@ describe Agents::SlackAgent do
15 18
 
16 19
     @event = Event.new
17 20
     @event.agent = agents(:bob_weather_agent)
18
-    @event.payload = { :channel => '#random', :message => 'Looks like its going to rain', username: "Huggin user"}
21
+    @event.payload = { :channel => '#random', :message => 'Looks like its going to rain', username: "Huggin user", fallback: @fallback}
19 22
     @event.save!
20 23
   end
21 24
 
@@ -44,12 +47,22 @@ describe Agents::SlackAgent do
44 47
       @checker.options['icon_emoji'] = "something"
45 48
       expect(@checker).to be_valid
46 49
     end
50
+
51
+    it "should allow attachments" do
52
+      @checker.options['attachments'] = nil
53
+      expect(@checker).to be_valid
54
+      @checker.options['attachments'] = []
55
+      expect(@checker).to be_valid
56
+      @checker.options['attachments'] = @attachments
57
+      expect(@checker).to be_valid
58
+    end
47 59
   end
48 60
 
49 61
   describe "#receive" do
50 62
     it "receive an event without errors" do
51 63
       any_instance_of(Slack::Notifier) do |obj|
52 64
         mock(obj).ping(@event.payload[:message],
65
+                       attachments: [{'fallback' => @fallback}],
53 66
                        channel: @event.payload[:channel],
54 67
                        username: @event.payload[:username]
55 68
                       )

+ 1 - 1
spec/models/agents/stubhub_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::StubhubAgent do
4 4
 

+ 1 - 1
spec/models/agents/translation_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TranslationAgent do
4 4
     before do

+ 72 - 15
spec/models/agents/trigger_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TriggerAgent do
4 4
   before do
@@ -58,6 +58,27 @@ describe Agents::TriggerAgent do
58 58
       expect(@checker).not_to be_valid
59 59
     end
60 60
 
61
+    it "validates that 'must_match' is a positive integer, not greater than the number of rules, if provided" do
62
+      @checker.options['must_match'] = '1'
63
+      expect(@checker).to be_valid
64
+
65
+      @checker.options['must_match'] = '0'
66
+      expect(@checker).not_to be_valid
67
+
68
+      @checker.options['must_match'] = 'wrong'
69
+      expect(@checker).not_to be_valid
70
+
71
+      @checker.options['must_match'] = ''
72
+      expect(@checker).to be_valid
73
+
74
+      @checker.options.delete('must_match')
75
+      expect(@checker).to be_valid
76
+
77
+      @checker.options['must_match'] = '2'
78
+      expect(@checker).not_to be_valid
79
+      expect(@checker.errors[:base].first).to match(/equal to or less than the number of rules/)
80
+    end
81
+
61 82
     it "should validate the three fields in each rule" do
62 83
       @checker.options['rules'] << { 'path' => "foo", 'type' => "fake", 'value' => "6" }
63 84
       expect(@checker).not_to be_valid
@@ -283,23 +304,59 @@ describe Agents::TriggerAgent do
283 304
       }.to change { Event.count }.by(2)
284 305
     end
285 306
 
286
-    it "handles ANDing rules together" do
287
-      @checker.options['rules'] << {
288
-        'type' => "field>=value",
289
-        'value' => "4",
290
-        'path' => "foo.bing"
291
-      }
307
+    describe "with multiple rules" do
308
+      before do
309
+        @checker.options['rules'] << {
310
+          'type' => "field>=value",
311
+          'value' => "4",
312
+          'path' => "foo.bing"
313
+        }
314
+      end
292 315
 
293
-      @event.payload['foo']["bing"] = "5"
316
+      it "handles ANDing rules together" do
317
+        @event.payload['foo']["bing"] = "5"
294 318
 
295
-      expect {
296
-        @checker.receive([@event])
297
-      }.to change { Event.count }.by(1)
319
+        expect {
320
+          @checker.receive([@event])
321
+        }.to change { Event.count }.by(1)
298 322
 
299
-      @checker.options['rules'].last['value'] = 6
300
-      expect {
301
-        @checker.receive([@event])
302
-      }.not_to change { Event.count }
323
+        @event.payload['foo']["bing"] = "2"
324
+
325
+        expect {
326
+          @checker.receive([@event])
327
+        }.not_to change { Event.count }
328
+      end
329
+
330
+      it "can accept a partial rule set match when 'must_match' is present and less than the total number of rules" do
331
+        @checker.options['must_match'] = "1"
332
+
333
+        @event.payload['foo']["bing"] = "5" # 5 > 4
334
+
335
+        expect {
336
+          @checker.receive([@event])
337
+        }.to change { Event.count }.by(1)
338
+
339
+        @event.payload['foo']["bing"] = "2" # 2 !> 4
340
+
341
+        expect {
342
+          @checker.receive([@event])
343
+        }.to change { Event.count }         # but the first one matches
344
+
345
+
346
+        @checker.options['must_match'] = "2"
347
+
348
+        @event.payload['foo']["bing"] = "5" # 5 > 4
349
+
350
+        expect {
351
+          @checker.receive([@event])
352
+        }.to change { Event.count }.by(1)
353
+
354
+        @event.payload['foo']["bing"] = "2" # 2 !> 4
355
+
356
+        expect {
357
+          @checker.receive([@event])
358
+        }.not_to change { Event.count }     # only 1 matches, we needed 2
359
+      end
303 360
     end
304 361
 
305 362
     describe "when 'keep_event' is true" do

+ 79 - 31
spec/models/agents/tumblr_publish_agent_spec.rb

@@ -1,38 +1,86 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TumblrPublishAgent do
4
-  before do
5
-    @opts = {
6
-      :blog_name => "huginnbot.tumblr.com",
7
-      :post_type => "text",
8
-      :expected_update_period_in_days => "2",
9
-      :options => {
10
-        :title => "{{title}}",
11
-        :body => "{{body}}",
12
-      },
13
-    }
14
-
15
-    @checker = Agents::TumblrPublishAgent.new(:name => "HuginnBot", :options => @opts)
16
-    @checker.service = services(:generic)
17
-    @checker.user = users(:bob)
18
-    @checker.save!
19
-
20
-    @event = Event.new
21
-    @event.agent = agents(:bob_weather_agent)
22
-    @event.payload = { :title => "Gonna rain...", :body => 'San Francisco is gonna get wet' }
23
-    @event.save!
24
-
25
-    stub.any_instance_of(Agents::TumblrPublishAgent).tumblr {
26
-      stub!.text(anything, anything) { { "id" => "5" } }
27
-    }
4
+  describe "Should create post" do
5
+    before do
6
+      @opts = {
7
+        :blog_name => "huginnbot.tumblr.com",
8
+        :post_type => "text",
9
+        :expected_update_period_in_days => "2",
10
+        :options => {
11
+          :title => "{{title}}",
12
+          :body => "{{body}}",
13
+        },
14
+      }
15
+
16
+      @checker = Agents::TumblrPublishAgent.new(:name => "HuginnBot", :options => @opts)
17
+      @checker.service = services(:generic)
18
+      @checker.user = users(:bob)
19
+      @checker.save!
20
+
21
+      @event = Event.new
22
+      @event.agent = agents(:bob_weather_agent)
23
+      @event.payload = { :title => "Gonna rain...", :body => 'San Francisco is gonna get wet' }
24
+      @event.save!
25
+
26
+      @post_body = {
27
+        "id" => 5,
28
+        "title" => "Gonna rain...",
29
+        "link" => "http://huginnbot.tumblr.com/gonna-rain..."
30
+      }
31
+      stub.any_instance_of(Agents::TumblrPublishAgent).tumblr {
32
+        obj = Object.new
33
+        stub(obj).text(anything, anything) { { "id" => "5" } }
34
+        stub(obj).posts("huginnbot.tumblr.com", {:id => "5"}) {
35
+          {"posts" => [@post_body]}
36
+        }
37
+      }
38
+
39
+    end
40
+
41
+    describe '#receive' do
42
+      it 'should publish any payload it receives' do
43
+        Agents::TumblrPublishAgent.async_receive(@checker.id, [@event.id])
44
+        expect(@checker.events.count).to eq(1)
45
+        expect(@checker.events.first.payload['post_id']).to eq('5')
46
+        expect(@checker.events.first.payload['published_post']).to eq('[huginnbot.tumblr.com] text')
47
+        expect(@checker.events.first.payload["post"]).to eq @post_body
48
+      end
49
+    end
28 50
   end
29 51
 
30
-  describe '#receive' do
31
-    it 'should publish any payload it receives' do
32
-      Agents::TumblrPublishAgent.async_receive(@checker.id, [@event.id])
33
-      expect(@checker.events.count).to eq(1)
34
-      expect(@checker.events.first.payload['post_id']).to eq('5')
35
-      expect(@checker.events.first.payload['published_post']).to eq('[huginnbot.tumblr.com] text')
52
+  describe "Should handle tumblr error" do
53
+    before do
54
+      @opts = {
55
+        :blog_name => "huginnbot.tumblr.com",
56
+        :post_type => "text",
57
+        :expected_update_period_in_days => "2",
58
+        :options => {
59
+          :title => "{{title}}",
60
+          :body => "{{body}}",
61
+        },
62
+      }
63
+
64
+      @checker = Agents::TumblrPublishAgent.new(:name => "HuginnBot", :options => @opts)
65
+      @checker.service = services(:generic)
66
+      @checker.user = users(:bob)
67
+      @checker.save!
68
+
69
+      @event = Event.new
70
+      @event.agent = agents(:bob_weather_agent)
71
+      @event.payload = { :title => "Gonna rain...", :body => 'San Francisco is gonna get wet' }
72
+      @event.save!
73
+
74
+      stub.any_instance_of(Agents::TumblrPublishAgent).tumblr {
75
+        stub!.text(anything, anything) { {"status" => 401,"msg" => "Not Authorized"} }
76
+      }
77
+    end
78
+
79
+    describe '#receive' do
80
+      it 'should publish any payload it receives and handle error' do
81
+        Agents::TumblrPublishAgent.async_receive(@checker.id, [@event.id])
82
+        expect(@checker.events.count).to eq(0)
83
+      end
36 84
     end
37 85
   end
38 86
 end

+ 1 - 1
spec/models/agents/twilio_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TwilioAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/twitter_publish_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TwitterPublishAgent do
4 4
   before do

+ 43 - 0
spec/models/agents/twitter_search_agent_spec.rb

@@ -0,0 +1,43 @@
1
+require 'rails_helper'
2
+
3
+describe Agents::TwitterSearchAgent do
4
+  before do
5
+    # intercept the twitter API request
6
+    stub_request(:any, /freebandnames/).to_return(body: File.read(Rails.root.join("spec/data_fixtures/search_tweets.json")), status: 200)
7
+
8
+    @opts = {
9
+      search: "freebandnames",
10
+      expected_update_period_in_days: "2",
11
+      starting_at: "Jan 01 00:00:01 +0000 2000",
12
+      max_results: '3'
13
+    }
14
+
15
+  end
16
+  let(:checker) {
17
+    _checker = Agents::TwitterSearchAgent.new(name: "search freebandnames", options: @opts)
18
+    _checker.service = services(:generic)
19
+    _checker.user = users(:bob)
20
+    _checker.save!
21
+    _checker
22
+  }
23
+
24
+  describe "#check" do
25
+    it "should check for changes" do
26
+      expect { checker.check }.to change { Event.count }.by(3)
27
+    end
28
+  end
29
+
30
+  describe "#check with starting_at=future date" do
31
+    it "should check for changes starting_at a future date, thus not find any" do
32
+      opts = @opts.merge({ starting_at: "Jan 01 00:00:01 +0000 2999" })
33
+
34
+      checker = Agents::TwitterSearchAgent.new(name: "search freebandnames", options: opts)
35
+      checker.service = services(:generic)
36
+      checker.user = users(:bob)
37
+      checker.save!
38
+
39
+      expect { checker.check }.to change { Event.count }.by(0)
40
+    end
41
+  end
42
+
43
+end

+ 2 - 1
spec/models/agents/twitter_stream_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TwitterStreamAgent do
4 4
   before do
@@ -193,6 +193,7 @@ describe Agents::TwitterStreamAgent do
193 193
     context "#stop" do
194 194
       it "stops the thread" do
195 195
         mock(@worker.thread).terminate
196
+        mock(@worker.thread).status
196 197
         @worker.stop
197 198
       end
198 199
     end

+ 1 - 1
spec/models/agents/twitter_user_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::TwitterUserAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/user_location_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::UserLocationAgent do
4 4
   before do

+ 28 - 0
spec/models/agents/weather_agent_spec.rb

@@ -0,0 +1,28 @@
1
+require 'rails_helper'
2
+
3
+describe Agents::WeatherAgent do
4
+  let(:agent) do
5
+    Agents::WeatherAgent.create(
6
+      name: 'weather',
7
+      options: { 
8
+        :location => 94103, 
9
+        :lat => 37.779329, 
10
+        :lng => -122.41915, 
11
+        :api_key => 'test' 
12
+      }
13
+    ).tap do |agent|
14
+      agent.user = users(:bob)  
15
+      agent.save!
16
+    end
17
+  end
18
+  
19
+  it "creates a valid agent" do
20
+    expect(agent).to be_valid
21
+  end
22
+  
23
+  describe "#service" do
24
+    it "doesn't have a Service object attached" do
25
+      expect(agent.service).to be_nil
26
+    end
27
+  end
28
+end

+ 23 - 2
spec/models/agents/webhook_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::WebhookAgent do
4 4
   let(:agent) do
@@ -30,7 +30,7 @@ describe Agents::WebhookAgent do
30 30
       expect(Event.last.payload).to eq({ 'name' => 'jon' })
31 31
     end
32 32
 
33
-    it 'should not create event if secrets dont match' do
33
+    it 'should not create event if secrets do not match' do
34 34
       out = nil
35 35
       expect {
36 36
         out = agent.receive_web_request({ 'secret' => 'bazbat', 'some_key' => payload }, "post", "text/html")
@@ -38,6 +38,27 @@ describe Agents::WebhookAgent do
38 38
       expect(out).to eq(['Not Authorized', 401])
39 39
     end
40 40
 
41
+    it 'should respond with customized response message if configured with `response` option' do
42
+      agent.options['response'] = 'That Worked'
43
+      out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
44
+      expect(out).to eq(['That Worked', 201])
45
+
46
+      # Empty string is a valid response
47
+      agent.options['response'] = ''
48
+      out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
49
+      expect(out).to eq(['', 201])
50
+    end
51
+
52
+    it 'should respond with `Event Created` if the response option is nil or missing' do
53
+      agent.options['response'] = nil
54
+      out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
55
+      expect(out).to eq(['Event Created', 201])
56
+
57
+      agent.options.delete('response')
58
+      out = agent.receive_web_request({ 'secret' => 'foobar', 'some_key' => payload }, "post", "text/html")
59
+      expect(out).to eq(['Event Created', 201])
60
+    end
61
+
41 62
     describe "receiving events" do
42 63
 
43 64
       context "default settings" do

+ 164 - 14
spec/models/agents/website_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::WebsiteAgent do
4 4
   describe "checking without basic auth" do
@@ -233,7 +233,7 @@ describe Agents::WebsiteAgent do
233 233
           to_return(body: 'hello',
234 234
                     status: 200)
235 235
         stub_request(:any, /deflate/).with(headers: { 'Accept-Encoding' => /deflate/ }).
236
-          to_return(body: '\xcb\x48\xcd\xc9\xc9\x07\x00\x06\x2c'.force_encoding(Encoding::ASCII_8BIT),
236
+          to_return(body: "\xcb\x48\xcd\xc9\xc9\x07\x00\x06\x2c".b,
237 237
                     headers: { 'Content-Encoding' => 'deflate' },
238 238
                     status: 200)
239 239
 
@@ -262,11 +262,11 @@ describe Agents::WebsiteAgent do
262 262
     describe 'encoding' do
263 263
       it 'should be forced with force_encoding option' do
264 264
         huginn = "\u{601d}\u{8003}"
265
-        stub_request(:any, /no-encoding/).to_return(:body => {
266
-            :value => huginn,
267
-          }.to_json.encode(Encoding::EUC_JP), :headers => {
265
+        stub_request(:any, /no-encoding/).to_return(body: {
266
+            value: huginn,
267
+          }.to_json.encode(Encoding::EUC_JP).b, headers: {
268 268
             'Content-Type' => 'application/json',
269
-          }, :status => 200)
269
+          }, status: 200)
270 270
         site = {
271 271
           'name' => "Some JSON Response",
272 272
           'expected_update_period_in_days' => "2",
@@ -278,22 +278,22 @@ describe Agents::WebsiteAgent do
278 278
           },
279 279
           'force_encoding' => 'EUC-JP',
280 280
         }
281
-        checker = Agents::WebsiteAgent.new(:name => "No Encoding Site", :options => site)
281
+        checker = Agents::WebsiteAgent.new(name: "No Encoding Site", options: site)
282 282
         checker.user = users(:bob)
283 283
         checker.save!
284 284
 
285
-        checker.check
285
+        expect { checker.check }.to change { Event.count }.by(1)
286 286
         event = Event.last
287 287
         expect(event.payload['value']).to eq(huginn)
288 288
       end
289 289
 
290 290
       it 'should be overridden with force_encoding option' do
291 291
         huginn = "\u{601d}\u{8003}"
292
-        stub_request(:any, /wrong-encoding/).to_return(:body => {
293
-            :value => huginn,
294
-          }.to_json.encode(Encoding::EUC_JP), :headers => {
292
+        stub_request(:any, /wrong-encoding/).to_return(body: {
293
+            value: huginn,
294
+          }.to_json.encode(Encoding::EUC_JP).b, headers: {
295 295
             'Content-Type' => 'application/json; UTF-8',
296
-          }, :status => 200)
296
+          }, status: 200)
297 297
         site = {
298 298
           'name' => "Some JSON Response",
299 299
           'expected_update_period_in_days' => "2",
@@ -305,11 +305,63 @@ describe Agents::WebsiteAgent do
305 305
           },
306 306
           'force_encoding' => 'EUC-JP',
307 307
         }
308
-        checker = Agents::WebsiteAgent.new(:name => "Wrong Encoding Site", :options => site)
308
+        checker = Agents::WebsiteAgent.new(name: "Wrong Encoding Site", options: site)
309 309
         checker.user = users(:bob)
310 310
         checker.save!
311 311
 
312
-        checker.check
312
+        expect { checker.check }.to change { Event.count }.by(1)
313
+        event = Event.last
314
+        expect(event.payload['value']).to eq(huginn)
315
+      end
316
+
317
+      it 'should be determined by charset in Content-Type' do
318
+        huginn = "\u{601d}\u{8003}"
319
+        stub_request(:any, /charset-euc-jp/).to_return(body: {
320
+            value: huginn,
321
+          }.to_json.encode(Encoding::EUC_JP), headers: {
322
+            'Content-Type' => 'application/json; charset=EUC-JP',
323
+          }, status: 200)
324
+        site = {
325
+          'name' => "Some JSON Response",
326
+          'expected_update_period_in_days' => "2",
327
+          'type' => "json",
328
+          'url' => "http://charset-euc-jp.example.com",
329
+          'mode' => 'on_change',
330
+          'extract' => {
331
+            'value' => { 'path' => 'value' },
332
+          },
333
+        }
334
+        checker = Agents::WebsiteAgent.new(name: "Charset reader", options: site)
335
+        checker.user = users(:bob)
336
+        checker.save!
337
+
338
+        expect { checker.check }.to change { Event.count }.by(1)
339
+        event = Event.last
340
+        expect(event.payload['value']).to eq(huginn)
341
+      end
342
+
343
+      it 'should default to UTF-8 when unknown charset is found' do
344
+        huginn = "\u{601d}\u{8003}"
345
+        stub_request(:any, /charset-unknown/).to_return(body: {
346
+            value: huginn,
347
+          }.to_json.b, headers: {
348
+            'Content-Type' => 'application/json; charset=unicode',
349
+          }, status: 200)
350
+        site = {
351
+          'name' => "Some JSON Response",
352
+          'expected_update_period_in_days' => "2",
353
+          'type' => "json",
354
+          'url' => "http://charset-unknown.example.com",
355
+          'mode' => 'on_change',
356
+          'extract' => {
357
+            'value' => { 'path' => 'value' },
358
+          },
359
+        }
360
+        checker = Agents::WebsiteAgent.new(name: "Charset reader", options: site)
361
+        checker.user = users(:bob)
362
+        checker.save!
363
+
364
+        expect { checker.check }.to change { Event.count }.by(1)
313 365
         event = Event.last
314 366
         expect(event.payload['value']).to eq(huginn)
315 367
       end
@@ -529,6 +581,41 @@ describe Agents::WebsiteAgent do
529 581
         end
530 582
       end
531 583
 
584
+      describe "XML with cdata" do
585
+        before do
586
+          stub_request(:any, /cdata_rss/).to_return(
587
+            body: File.read(Rails.root.join("spec/data_fixtures/cdata_rss.atom")),
588
+            status: 200
589
+          )
590
+
591
+          @checker = Agents::WebsiteAgent.new(name: 'cdata', options: {
592
+            'name' => 'CDATA',
593
+            'expected_update_period_in_days' => '2',
594
+            'type' => 'xml',
595
+            'url' => 'http://example.com/cdata_rss.atom',
596
+            'mode' => 'on_change',
597
+            'extract' => {
598
+              'author' => { 'xpath' => '/feed/entry/author/name', 'value' => './/text()'},
599
+              'title' => { 'xpath' => '/feed/entry/title', 'value' => './/text()' },
600
+              'content' => { 'xpath' => '/feed/entry/content', 'value' => './/text()' },
601
+            }
602
+          }, keep_events_for: 2.days)
603
+          @checker.user = users(:bob)
604
+          @checker.save!
605
+        end
606
+
607
+        it "works with XPath" do
608
+          expect {
609
+            @checker.check
610
+          }.to change { Event.count }.by(10)
611
+          event = Event.last
612
+          expect(event.payload['author']).to eq('bill98')
613
+          expect(event.payload['title']).to eq('Help: Rainmeter Skins • Test if Today is Between 2 Dates')
614
+          expect(event.payload['content']).to start_with('Can I ')
615
+        end
616
+
617
+      end
618
+
532 619
       describe "JSON" do
533 620
         it "works with paths" do
534 621
           json = {
@@ -824,4 +911,67 @@ fire: hot
824 911
       end
825 912
     end
826 913
   end
914
+
915
+  describe "checking urls" do
916
+    before do
917
+      stub_request(:any, /example/).
918
+        to_return(:body => File.read(Rails.root.join("spec/data_fixtures/urlTest.html")), :status => 200)
919
+      @valid_options = {
920
+        'name' => "Url Test",
921
+        'expected_update_period_in_days' => "2",
922
+        'type' => "html",
923
+        'url' => "http://www.example.com",
924
+        'mode' => 'all',
925
+        'extract' => {
926
+          'url' => { 'css' => "a", 'value' => "@href" },
927
+        }
928
+      }
929
+      @checker = Agents::WebsiteAgent.new(:name => "ua", :options => @valid_options)
930
+      @checker.user = users(:bob)
931
+      @checker.save!
932
+    end
933
+
934
+    describe "#check" do
935
+      before do
936
+        expect { @checker.check }.to change { Event.count }.by(7)
937
+        @events = Event.last(7)
938
+      end
939
+
940
+      it "should check hostname" do
941
+        event = @events[0]
942
+        expect(event.payload['url']).to eq("http://google.com")
943
+      end
944
+
945
+      it "should check unescaped query" do
946
+        event = @events[1]
947
+        expect(event.payload['url']).to eq("https://www.google.ca/search?q=some%20query")
948
+      end
949
+
950
+      it "should check properly escaped query" do
951
+        event = @events[2]
952
+        expect(event.payload['url']).to eq("https://www.google.ca/search?q=some%20query")
953
+      end
954
+
955
+      it "should check unescaped unicode url" do
956
+        event = @events[3]
957
+        expect(event.payload['url']).to eq("http://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8")
958
+      end
959
+
960
+      it "should check unescaped unicode query" do
961
+        event = @events[4]
962
+        expect(event.payload['url']).to eq("https://www.google.ca/search?q=%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8")
963
+      end
964
+
965
+      it "should check properly escaped unicode url" do
966
+        event = @events[5]
967
+        expect(event.payload['url']).to eq("http://ko.wikipedia.org/wiki/%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8")
968
+      end
969
+
970
+      it "should check properly escaped unicode query" do
971
+        event = @events[6]
972
+        expect(event.payload['url']).to eq("https://www.google.ca/search?q=%EC%9C%84%ED%82%A4%EB%B0%B1%EA%B3%BC:%EB%8C%80%EB%AC%B8")
973
+      end
974
+
975
+    end
976
+  end
827 977
 end

+ 1 - 1
spec/models/agents/weibo_publish_agent_spec.rb

@@ -1,5 +1,5 @@
1 1
 # encoding: utf-8 
2
-require 'spec_helper'
2
+require 'rails_helper'
3 3
 
4 4
 describe Agents::WeiboPublishAgent do
5 5
   before do

+ 1 - 1
spec/models/agents/weibo_user_agent_spec.rb

@@ -1,5 +1,5 @@
1 1
 # encoding: utf-8 
2
-require 'spec_helper'
2
+require 'rails_helper'
3 3
 
4 4
 describe Agents::WeiboUserAgent do
5 5
   before do

+ 1 - 1
spec/models/agents/witai_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Agents::WitaiAgent do
4 4
   before do

+ 1 - 1
spec/models/agents/wunderlist_agent_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 require 'models/concerns/oauthable'
3 3
 
4 4
 describe Agents::WunderlistAgent do

+ 1 - 1
spec/models/concerns/oauthable.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 module Agents
4 4
   class OauthableTestAgent < Agent

+ 1 - 1
spec/models/event_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Event do
4 4
   describe ".with_location" do

+ 1 - 1
spec/models/scenario_import_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe ScenarioImport do
4 4
   let(:user) { users(:bob) }

+ 1 - 1
spec/models/scenario_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Scenario do
4 4
   let(:new_instance) { users(:bob).scenarios.build(:name => "some scenario") }

+ 1 - 1
spec/models/service_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe Service do
4 4
   before(:each) do

+ 8 - 8
spec/models/user_credential_spec.rb

@@ -1,19 +1,19 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe UserCredential do
4 4
   describe "validation" do
5
-    it { is_expected.to validate_uniqueness_of(:credential_name).scoped_to(:user_id) }
6
-    it { is_expected.to validate_presence_of(:credential_name) }
7
-    it { is_expected.to validate_presence_of(:credential_value) }
8
-    it { is_expected.to validate_presence_of(:user_id) }
5
+    it { should validate_uniqueness_of(:credential_name).scoped_to(:user_id) }
6
+    it { should validate_presence_of(:credential_name) }
7
+    it { should validate_presence_of(:credential_value) }
8
+    it { should validate_presence_of(:user_id) }
9 9
   end
10 10
 
11 11
   describe "mass assignment" do
12
-    it { is_expected.to allow_mass_assignment_of :credential_name }
12
+    it { should allow_mass_assignment_of :credential_name }
13 13
 
14
-    it { is_expected.to allow_mass_assignment_of :credential_value }
14
+    it { should allow_mass_assignment_of :credential_value }
15 15
 
16
-    it { is_expected.not_to allow_mass_assignment_of :user_id }
16
+    it { should_not allow_mass_assignment_of :user_id }
17 17
   end
18 18
 
19 19
   describe "cleaning fields" do

+ 4 - 4
spec/models/users_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe User do
4 4
   describe "validations" do
@@ -10,13 +10,13 @@ describe User do
10 10
         
11 11
         it "only accepts valid invitation codes" do
12 12
           User::INVITATION_CODES.each do |v|
13
-            is_expected.to allow_value(v).for(:invitation_code)
13
+            should allow_value(v).for(:invitation_code)
14 14
           end
15 15
         end
16 16
   
17 17
         it "can reject invalid invitation codes" do
18 18
           %w['foo', 'bar'].each do |v|
19
-            is_expected.not_to allow_value(v).for(:invitation_code)
19
+            should_not allow_value(v).for(:invitation_code)
20 20
           end
21 21
         end
22 22
       end
@@ -28,7 +28,7 @@ describe User do
28 28
         
29 29
         it "skips this validation" do
30 30
           %w['foo', 'bar', nil, ''].each do |v|
31
-            is_expected.to allow_value(v).for(:invitation_code)
31
+            should allow_value(v).for(:invitation_code)
32 32
           end
33 33
         end
34 34
       end

+ 1 - 1
spec/presenters/form_configurable_agent_presenter_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe FormConfigurableAgentPresenter do
4 4
   include RSpecHtmlMatchers

+ 8 - 0
spec/spec_helper.rb

@@ -21,6 +21,14 @@ Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f }
21 21
 
22 22
 ActiveRecord::Migration.maintain_test_schema!
23 23
 
24
+# Mix in shoulda matchers
25
+Shoulda::Matchers.configure do |config|
26
+  config.integrate do |with|
27
+    with.test_framework :rspec
28
+    with.library :rails
29
+  end
30
+end
31
+
24 32
 RSpec.configure do |config|
25 33
   config.mock_with :rr
26 34
 

+ 1 - 1
spec/routing/webhooks_controller_spec.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 describe "routing for web requests", :type => :routing do
4 4
   it "routes to handle_request" do

+ 20 - 1
spec/support/shared_examples/agent_controller_concern.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for AgentControllerConcern do
4 4
   describe "preconditions" do
@@ -128,5 +128,24 @@ shared_examples_for AgentControllerConcern do
128 128
       expect(agent.control_targets.reload).to all(satisfy { |a| a.options['url'] == 'http://some-new-url.com/SOMETHING' })
129 129
       expect(agents(:bob_website_agent).reload.options).to eq(old_options.merge('url' => 'http://some-new-url.com/SOMETHING'))
130 130
     end
131
+
132
+    it "should configure targets with nested objects" do
133
+      agent.control_targets << agents(:bob_data_output_agent)
134
+      agent.options['action'] = 'configure'
135
+      agent.options['configure_options'] = { 
136
+        template: {
137
+          item: {
138
+           title: "changed"
139
+          }
140
+        }
141
+      }
142
+      agent.save!
143
+      old_options = agents(:bob_data_output_agent).options
144
+
145
+      agent.control!
146
+
147
+      expect(agent.control_targets.reload).to all(satisfy { |a| a.options['template'] && a.options['template']['item'] && (a.options['template']['item']['title'] == 'changed') })
148
+      expect(agents(:bob_data_output_agent).reload.options).to eq(old_options.deep_merge(agent.options['configure_options']))
149
+    end
131 150
   end
132 151
 end

+ 1 - 1
spec/support/shared_examples/email_concern.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for EmailConcern do
4 4
   let(:valid_options) {

+ 1 - 1
spec/support/shared_examples/has_guid.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for HasGuid do
4 4
   it "gets created before_save, but only if it's not present" do

+ 2 - 2
spec/support/shared_examples/liquid_interpolatable.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for LiquidInterpolatable do
4 4
   before(:each) do
@@ -94,7 +94,7 @@ shared_examples_for LiquidInterpolatable do
94 94
       it "should raise an exception for undefined credentials" do
95 95
         expect {
96 96
           @checker.interpolate_string("{% credential unknown %}", {})
97
-        }.to raise_error
97
+        }.to raise_error(/No user credential named/)
98 98
       end
99 99
     end
100 100
 

+ 1 - 1
spec/support/shared_examples/web_request_concern.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for WebRequestConcern do
4 4
   let(:agent) do

+ 1 - 1
spec/support/shared_examples/working_helpers.rb

@@ -1,4 +1,4 @@
1
-require 'spec_helper'
1
+require 'rails_helper'
2 2
 
3 3
 shared_examples_for WorkingHelpers do
4 4
   describe "recent_error_logs?" do