Skip to content
Snippets Groups Projects
Unverified Commit 6c429a9f authored by Cam Saul's avatar Cam Saul Committed by GitHub
Browse files

Fix Slack integration & make it more robust (#12921)

* Fix Slack integration

* metabase.pulse-test cleanup :wrench: :shower:

* Fixes :wrench:

* Lint fix :shirt:
parent 118ac17c
No related branches found
No related tags found
No related merge requests found
......@@ -126,7 +126,7 @@
chan-types
;; if we have Slack enabled build a dynamic list of channels/users
(try
(let [slack-channels (for [channel (slack/channels-list)]
(let [slack-channels (for [channel (slack/conversations-list)]
(str \# (:name channel)))
slack-users (for [user (slack/users-list)]
(str \@ (:name user)))]
......
......@@ -5,7 +5,9 @@
[metabase.config :as config]
[metabase.integrations.slack :as slack]
[metabase.models.setting :as setting]
[metabase.util.schema :as su]
[metabase.util
[i18n :refer [tru]]
[schema :as su]]
[schema.core :as s]))
(api/defendpoint PUT "/settings"
......@@ -17,9 +19,9 @@
(if-not slack-token
(setting/set-many! {:slack-token nil, :metabot-enabled false})
(try
;; just check that channels.list doesn't throw an exception (a.k.a. that the token works)
(when-not config/is-test?
(slack/GET :channels.list, :exclude_archived 1, :token slack-token))
(when-not (slack/valid-token? slack-token)
(throw (ex-info (tru "Invalid Slack token.") {:status-code 400}))))
(setting/set-many! slack-settings)
{:ok true}
(catch clojure.lang.ExceptionInfo info
......
......@@ -4,12 +4,11 @@
[clojure.core.memoize :as memoize]
[clojure.java.io :as io]
[clojure.tools.logging :as log]
[metabase
[config :as config]
[util :as u]]
[medley.core :as m]
[metabase.models.setting :as setting :refer [defsetting]]
[metabase.util :as u]
[metabase.util
[i18n :refer [deferred-tru]]
[i18n :refer [deferred-tru trs tru]]
[schema :as su]]
[schema.core :as s]))
......@@ -24,75 +23,130 @@
[]
(boolean (seq (slack-token))))
(defn- handle-error [body]
(let [invalid-token? (= (:error body) "invalid_auth")
message (if invalid-token?
(tru "Invalid token")
(tru "Slack API error: {0}" (:error body)))
error (if invalid-token?
{:error-code (:error body)
:errors {:slack-token message}}
{:error-code (:error body)
:message message
:response body})]
(log/warn (u/pprint-to-str 'red error))
(throw (ex-info message error))))
(defn- handle-response [{:keys [status body]}]
(with-open [reader (io/reader body)]
(let [body (json/parse-stream reader keyword)]
(let [body (json/parse-stream reader true)]
(if (and (= 200 status) (:ok body))
body
(let [error (if (= (:error body) "invalid_auth")
{:errors {:slack-token "Invalid token"}}
{:message (str "Slack API error: " (:error body)), :response body})]
(log/warn (u/pprint-to-str 'red error))
(throw (ex-info (:message error) error)))))))
(defn- do-slack-request [request-fn params-key endpoint & {:keys [token], :as params, :or {token (slack-token)}}]
(when token
(handle-response (request-fn (str slack-api-base-url "/" (name endpoint)) {params-key (assoc params :token token)
:as :stream
:conn-timeout 1000
:socket-timeout 1000}))))
(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1}
GET
(handle-error body)))))
(defn- do-slack-request [request-fn endpoint request]
(let [token (or (get-in request [:query-params :token])
(get-in request [:form-params :token])
(slack-token))]
(when token
(let [url (str slack-api-base-url "/" (name endpoint))
_ (log/trace "Slack API request: %s %s" (pr-str url) (pr-str request))
request (merge-with merge
{:query-params {:token token}
:as :stream
:conn-timeout 1000
:socket-timeout 1000}
request)]
(try
(handle-response (request-fn url request))
(catch Throwable e
(throw (ex-info (.getMessage e) (merge (ex-data e) {:url url, :request request}) e))))))))
(defn GET
"Make a GET request to the Slack API."
(partial do-slack-request http/get :query-params))
[endpoint & {:as query-params}]
(do-slack-request http/get endpoint {:query-params query-params}))
(def ^{:arglists '([endpoint & {:as params}]), :style/indent 1}
POST
(defn POST
"Make a POST request to the Slack API."
(partial do-slack-request http/post :form-params))
(def ^{:arglists '([& {:as args}])} channels-list
"Calls Slack api `channels.list` function and returns the list of available channels."
(comp :channels (partial GET :channels.list, :exclude_archived true, :exclude_members true)))
(def ^{:arglists '([& {:as args}])} users-list
"Calls Slack api `users.list` function and returns the list of available users."
(comp :members (partial GET :users.list)))
(def ^:private ^String channel-missing-msg
(str "Slack channel named `metabase_files` is missing! Please create the channel in order to complete "
"the Slack integration. The channel is used for storing graphs that are included in pulses and "
"MetaBot answers."))
(defn- maybe-get-files-channel
"Return the `metabase_files channel (as a map) if it exists."
[]
(some (fn [channel] (when (= (:name channel) files-channel-name)
channel))
(channels-list :exclude_archived false)))
[endpoint body]
(do-slack-request http/post endpoint {:form-params body}))
(defn- next-cursor
"Get a cursor for the next page of results in a Slack API response, if one exists."
[response]
(not-empty (get-in response [:response_metadata :next_cursor])))
(def ^:private max-list-results
"Absolute maximum number of results to fetch from Slack API list endpoints. To prevent unbounded pagination of results."
1000)
(defn- paged-list-request
"Make a GET request to a Slack API list `endpoint`, returning a sequence of objects returned by the top level
`results-key` in the response. If additional pages of results exist, fetches those lazily, up to a total of
`max-list-results`."
[endpoint results-key params]
(let [response (m/mapply GET endpoint params)]
(when (seq response)
(take
max-list-results
(concat
(get response results-key)
(when-let [next-cursor (next-cursor response)]
(lazy-seq
(paged-list-request endpoint results-key (assoc params :cursor next-cursor)))))))))
(defn conversations-list
"Calls Slack API `conversations.list` and returns list of available 'conversations' (channels and direct messages). By
default only fetches channels."
[& {:as query-parameters}]
(let [params (merge {:exclude_archived true, :types "public_channel,private_channel"} query-parameters)]
(paged-list-request "conversations.list" :channels params)))
(defn- channel-with-name
"Return a Slack channel with `channel-name` (as a map) if it exists."
[channel-name]
(some (fn [channel]
(when (= (:name channel) channel-name)
channel))
(conversations-list :exclude_archived false)))
(s/defn valid-token?
"Check whether a Slack token is valid by checking whether we can call `conversations.list` with it."
[token :- su/NonBlankString]
(try
(boolean (take 1 (conversations-list :limit 1, :token token)))
(catch Throwable e
(if (= (:error-code (ex-data e)) "invalid_auth")
false
(throw e)))))
(defn users-list
"Calls Slack API `users.list` endpoint and returns the list of available users."
[& {:as query-parameters}]
(paged-list-request "users.list" :members query-parameters))
(defn- files-channel* []
(or (maybe-get-files-channel)
(do (log/error (u/format-color 'red channel-missing-msg))
(throw (ex-info channel-missing-msg {:status-code 400})))))
(or (channel-with-name files-channel-name)
(let [message (str (tru "Slack channel named `metabase_files` is missing!")
" "
(tru "Please create the channel in order to complete the Slack integration.")
" "
(tru "The channel is used for storing graphs that are included in Pulses and MetaBot answers."))]
(log/error (u/format-color 'red message))
(throw (ex-info message {:status-code 400})))))
(def ^{:arglists '([])} files-channel
"Calls Slack api `channels.info` to check whether a channel named #metabase_files exists. If it doesn't, throws an
error that advices an admin to create it."
;; If the channel has successfully been created we can cache the information about it from the API response. We need
;; this information every time we send out a pulse, but making a call to the `channels.list` endpoint everytime we
;; this information every time we send out a pulse, but making a call to the `coversations.list` endpoint everytime we
;; send a Pulse can result in us seeing 429 (rate limiting) status codes -- see
;; https://github.com/metabase/metabase/issues/8967
;;
;; Of course, if `files-channel*` *fails* (because the channel is not created), this won't get cached; this is what
;; we want -- to remind people to create it
(if config/is-test?
;; don't cache the channel when running tests, because we don't actually hit the Slack API, and we don't want one
;; test causing their "fake" channel to get cached and mess up other tests
files-channel*
(let [six-hours-ms (* 6 60 60 1000)]
(memoize/ttl files-channel* :ttl/threshold six-hours-ms))))
(memoize/ttl files-channel* :ttl/threshold (u/hours->ms 6)))
(def ^:private NonEmptyByteArray
(s/constrained
......@@ -101,31 +155,31 @@
"Non-empty byte array"))
(s/defn upload-file!
"Calls Slack api `files.upload` function and returns the body of the uploaded file."
[file :- NonEmptyByteArray, filename :- su/NonBlankString, channel-ids-str :- su/NonBlankString]
"Calls Slack API `files.upload` endpoint and returns the URL of the uploaded file."
[file :- NonEmptyByteArray, filename :- su/NonBlankString, channel-id :- su/NonBlankString]
{:pre [(seq (slack-token))]}
(let [response (http/post (str slack-api-base-url "/files.upload") {:multipart [{:name "token", :content (slack-token)}
{:name "file", :content file}
{:name "filename", :content filename}
{:name "channels", :content channel-ids-str}]
{:name "channels", :content channel-id}]
:as :json})]
(if (= 200 (:status response))
(u/prog1 (get-in (:body response) [:file :url_private])
(log/debug "Uploaded image" <>))
(log/warn "Error uploading file to Slack:" (u/pprint-to-str response)))))
(u/prog1 (get-in response [:body :file :url_private])
(log/debug (trs "Uploaded image") <>))
(log/warn (trs "Error uploading file to Slack:") (u/pprint-to-str response)))))
(s/defn post-chat-message!
"Calls Slack api `chat.postMessage` function and posts a message to a given channel. `attachments` should be
serialized JSON."
"Calls Slack API `chat.postMessage` endpoint and posts a message to a channel. `attachments` should be serialized
JSON."
[channel-id :- su/NonBlankString, text-or-nil :- (s/maybe s/Str) & [attachments]]
;; TODO: it would be nice to have an emoji or icon image to use here
(POST :chat.postMessage
:channel channel-id
:username "MetaBot"
:icon_url "http://static.metabase.com/metabot_slack_avatar_whitebg.png"
:text text-or-nil
:attachments (when (seq attachments)
(json/generate-string attachments))))
(POST "chat.postMessage"
{:channel channel-id
:username "MetaBot"
:icon_url "http://static.metabase.com/metabot_slack_avatar_whitebg.png"
:text text-or-nil
:attachments (when (seq attachments)
(json/generate-string attachments))}))
(def ^{:arglists '([& {:as params}])} websocket-url
"Return a new WebSocket URL for [Slack's Real Time Messaging API](https://api.slack.com/rtm)
......
......@@ -849,8 +849,8 @@
(testing "GET /api/pulse/form_input"
(testing "Check that Slack channels come back when configured"
(mt/with-temporary-setting-values [slack-token "something"]
(with-redefs [slack/channels-list (constantly [{:name "foo"}])
slack/users-list (constantly [{:name "bar"}])]
(with-redefs [slack/conversations-list (constantly [{:name "foo"}])
slack/users-list (constantly [{:name "bar"}])]
(is (= [{:name "channel", :type "select", :displayName "Post to", :options ["#foo" "@bar"], :required true}]
(-> ((mt/user->client :rasta) :get 200 "pulse/form_input")
(get-in [:channels :slack :fields])))))))
......
(ns metabase.integrations.slack-test
(:require [cheshire.core :as json]
[clj-http.fake :as http-fake]
[clojure.core.memoize :as memoize]
[clojure.java.io :as io]
[expectations :refer :all]
[metabase.integrations.slack :as slack-integ :refer :all]
[metabase.test.util :as tu]))
(def ^:private default-channels-response
(delay (slurp (io/resource "slack_channels_response.json"))))
(def ^:private default-channels
(delay (:channels (json/parse-string @default-channels-response keyword))))
(def ^:private channels-request
{:address "https://slack.com/api/channels.list"
:query-params {:token "test-token"
:exclude_archived "true"
:exclude_members "true"}})
(defn- expected-200-response [body]
(fn [_]
{:status 200
:body (if (string? body)
[clojure.test :refer :all]
[medley.core :as m]
[metabase.integrations.slack :as slack]
[metabase.test.util :as tu]
[schema.core :as s])
(:import java.nio.charset.Charset
org.apache.commons.io.IOUtils
org.apache.http.client.utils.URLEncodedUtils
org.apache.http.NameValuePair))
(defn- parse-query-string [query-string]
(into {} (for [^NameValuePair pair (URLEncodedUtils/parse (str query-string) (Charset/forName "UTF-8"))]
[(keyword (.getName pair)) (.getValue pair)])))
(defn- mock-paged-response-body [{:keys [query-string], :as request} response-body]
(if (string? response-body)
(recur request (json/parse-string response-body true))
(let [{:keys [cursor]} (parse-query-string query-string)]
;; if the mock handler is called without a `cursor` param, return response with a `next_cursor`; if passed that
;; `cursor`, remove the `next_cursor`. That way we should get two pages total for testing paging
(if (seq cursor)
(m/dissoc-in response-body [:response_metadata :next_cursor])
response-body))))
(defn- mock-conversations-response-body [request]
(mock-paged-response-body request (slurp "./test_resources/slack_channels_response.json")))
(defn- mock-conversations []
(:channels (mock-conversations-response-body nil)))
(def ^:private conversations-endpoint #"^https://slack\.com/api/conversations\.list.*")
(defn- mock-200-response [body]
{:status 200
:body (if (string? body)
body
(json/generate-string body))}))
(def ^:private invalid-token-response
(expected-200-response
{:ok false
:error "invalid_auth"}))
(defn- exception-if-called [_]
(throw (Exception. "Failure, route should not have been invoked")))
;; Channels should return nil if no Slack token has been configured
(expect
nil
(http-fake/with-fake-routes {channels-request exception-if-called}
(tu/with-temporary-setting-values [slack-token nil]
(channels-list))))
;; Test the channels call and expected response
(expect
@default-channels
(http-fake/with-fake-routes {channels-request (expected-200-response @default-channels-response)}
(tu/with-temporary-setting-values [slack-token "test-token"]
(channels-list))))
;; Test the invalid token auth flow
(expect
{:ex-class clojure.lang.ExceptionInfo
:msg nil
:data {:errors {:slack-token "Invalid token"}}}
(http-fake/with-fake-routes {channels-request invalid-token-response}
(tu/with-temporary-setting-values [slack-token "test-token"]
(tu/exception-and-message
(channels-list)))))
(def ^:private default-users-response
(delay (slurp (or (io/resource "slack_users_response.json")
;; when running from the REPL
(slurp "./test_resources/slack_users_response.json")))))
(def ^:private default-users
(delay (:members (json/parse-string @default-users-response keyword))))
(def ^:private users-request
{:address "https://slack.com/api/users.list"
:query-params {:token "test-token"}})
;; Users should return nil if no Slack token has been configured
(expect
nil
(http-fake/with-fake-routes {users-request exception-if-called}
(tu/with-temporary-setting-values [slack-token nil]
(users-list))))
;; Test the users call and the expected response
(expect
@default-users
(http-fake/with-fake-routes {users-request (expected-200-response @default-users-response)}
(tu/with-temporary-setting-values [slack-token "test-token"]
(users-list))))
;; Test the invalid token auth flow for users
(expect
{:ex-class clojure.lang.ExceptionInfo
:msg nil
:data {:errors {:slack-token "Invalid token"}}}
(http-fake/with-fake-routes {users-request invalid-token-response}
(tu/with-temporary-setting-values [slack-token "test-token"]
(tu/exception-and-message
(users-list)))))
(def ^:private files-request
(assoc-in channels-request [:query-params :exclude_archived] "false"))
;; Asking for the files channel when slack is not configured throws an exception
(expect
{:ex-class clojure.lang.ExceptionInfo
:msg (var-get #'slack-integ/channel-missing-msg)
:data {:status-code 400}}
(http-fake/with-fake-routes {files-request exception-if-called}
(tu/exception-and-message
(files-channel))))
(defn- create-files-channel []
(let [channel-name (var-get #'slack-integ/files-channel-name)]
(-> @default-channels
(json/generate-string body))})
(defn- test-no-auth-token
"Test that a Slack API endpoint function returns `nil` if a Slack API token isn't configured."
[endpoint thunk]
(http-fake/with-fake-routes {endpoint (fn [_]
(throw (Exception. "Failure, route should not have been invoked")))}
(testing "should return nil if no Slack token has been configured"
(tu/with-temporary-setting-values [slack-token nil]
(is (= nil
(thunk)))))))
(defn- test-invalid-auth-token
"Test that a Slack API endpoint function throws an Exception if an invalid Slack API token is set."
[endpoint thunk]
(testing "should throw Exception if auth token is invalid"
(http-fake/with-fake-routes {endpoint (constantly
(mock-200-response {:ok false
:error "invalid_auth"}))}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Invalid token"
(thunk)))
(try
(thunk)
(catch clojure.lang.ExceptionInfo e
(is (= {:slack-token "Invalid token"}
(:errors (ex-data e))))))))))
(defn- test-auth
"Test that a Slack API `endpoint` function works as expected when Slack token is missing or invalid."
[endpoint thunk]
(doseq [f [test-no-auth-token test-invalid-auth-token]]
(f endpoint thunk)))
(deftest conversations-list-test
(testing "conversations-list"
(test-auth conversations-endpoint slack/conversations-list)
(testing "should be able to fetch channels and paginate"
(http-fake/with-fake-routes {conversations-endpoint (comp mock-200-response mock-conversations-response-body)}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (= (concat (mock-conversations) (mock-conversations))
(slack/conversations-list))))))))
(deftest valid-token?-test
(testing "valid-token?"
;; should ignore value of `slack-token` Setting
(doseq [setting-value ["test-token" nil]]
(tu/with-temporary-setting-values [slack-token setting-value]
(http-fake/with-fake-routes {conversations-endpoint (fn [{:keys [query-string], :as request}]
(let [{:keys [token]} (parse-query-string query-string)]
(testing "token passed as parameter should be used for the request"
(is (= "abc"
token))))
(mock-200-response (mock-conversations-response-body request)))}
(is (= true
(slack/valid-token? "abc"))))
(testing "invalid token should return false"
(http-fake/with-fake-routes {conversations-endpoint (constantly
(mock-200-response {:ok false
:error "invalid_auth"}))}
(is (= false
(slack/valid-token? "abc")))))
(testing "other error should be thrown as an Exception"
(http-fake/with-fake-routes {conversations-endpoint (constantly
(mock-200-response {:ok false
:error "some_other_error"}))}
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Slack API error: some_other_error"
(slack/valid-token? "abc")))))))))
(defn- mock-users-response-body [request]
(mock-paged-response-body request (slurp "./test_resources/slack_users_response.json")))
(defn- mock-users []
(:members (mock-users-response-body nil)))
(def ^:private users-endpoint #"^https://slack\.com/api/users\.list.*")
(deftest users-list-test
(testing "users-list"
(test-auth users-endpoint slack/users-list)
(testing "should be able to fetch list of users and page"
(http-fake/with-fake-routes {users-endpoint (comp mock-200-response mock-users-response-body)}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (= (concat (mock-users) (mock-users))
(slack/users-list))))))))
(defn- mock-files-channel []
(let [channel-name @#'slack/files-channel-name]
(-> (mock-conversations)
first
(assoc
:name channel-name, :name_normalized channel-name,
:purpose {:value "Metabase file upload location", :creator "", :last_set 0}))))
;; Testing the call that finds the metabase files channel
(expect
(create-files-channel)
(http-fake/with-fake-routes {files-request (-> @default-channels-response
json/parse-string
(update :channels conj (create-files-channel))
expected-200-response)}
(tu/with-temporary-setting-values [slack-token "test-token"]
(files-channel))))
:name channel-name, :name_normalized channel-name,
:purpose {:value "Metabase file upload location", :creator "", :last_set 0}))))
(deftest files-channel-test
;; clear out any cached valid values of `files-channel`
(memoize/memo-clear! @#'slack/files-channel)
(testing "files-channel"
(test-invalid-auth-token conversations-endpoint slack/files-channel)
(testing "Should be able to fetch the files-channel (if it exists)"
(http-fake/with-fake-routes {conversations-endpoint (fn [request]
(-> (mock-conversations-response-body request)
(update :channels conj (mock-files-channel))
mock-200-response))}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (= (mock-files-channel)
(slack/files-channel))))))))
(deftest upload-file!-test
(testing "upload-file!"
(let [image-bytes (with-open [is (io/input-stream (io/resource "frontend_client/favicon.ico"))]
(IOUtils/toByteArray is))
filename "wow.gif"
channel-id "C13372B6X"]
(http-fake/with-fake-routes {#"^https://slack.com/api/files\.upload.*" (fn [_]
(mock-200-response (slurp "./test_resources/slack_upload_file_response.json")))}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (= "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif"
(slack/upload-file! image-bytes filename channel-id))))))))
(deftest post-chat-message!-test
(testing "post-chat-message!"
(http-fake/with-fake-routes {#"^https://slack.com/api/chat\.postMessage.*" (fn [_]
(mock-200-response (slurp "./test_resources/slack_post_chat_message_response.json")))}
(tu/with-temporary-setting-values [slack-token "test-token"]
(is (schema= {:ok (s/eq true)
:message {:type (s/eq "message")
:subtype (s/eq "bot_message")
:text (s/eq ":wow:")
s/Keyword s/Any}
s/Keyword s/Any}
(slack/post-chat-message! "C94712B6X" ":wow:")))))))
......@@ -4,47 +4,51 @@
[test :refer :all]
[walk :as walk]]
[clojure.java.io :as io]
[expectations :refer [expect]]
[medley.core :as m]
[metabase
[email-test :as et]
[models :refer [Card Collection Pulse PulseCard PulseChannel PulseChannelRecipient]]
[pulse :as pulse]
[test :as mt]]
[test :as mt]
[util :as u]]
[metabase.integrations.slack :as slack]
[metabase.models
[card :refer [Card]]
[collection :refer [Collection]]
[permissions :as perms]
[permissions-group :as group]
[pulse :as models.pulse :refer [Pulse]]
[pulse-card :refer [PulseCard]]
[pulse-channel :refer [PulseChannel]]
[pulse-channel-recipient :refer [PulseChannelRecipient]]]
[pulse :as models.pulse]]
[metabase.pulse.render.body :as render.body]
[metabase.pulse.test-util :as pulse.tu]
[metabase.query-processor.middleware.constraints :as constraints]
[metabase.test
[data :as data]
[util :as tu]]
[metabase.test.data.users :as users]
[schema.core :as s]
[toucan.db :as db]
[toucan.util.test :as tt]))
[toucan.db :as db]))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Util Fns & Macros |
;;; +----------------------------------------------------------------------------------------------------------------+
(def ^:private card-name "Test card")
(defn checkins-query
(defn checkins-query-card*
"Basic query that will return results for an alert"
[query-map]
{:name card-name
:dataset_query {:database (data/id)
:dataset_query {:database (mt/id)
:type :query
:query (merge {:source-table (data/id :checkins)
:query (merge {:source-table (mt/id :checkins)
:aggregation [["count"]]}
query-map)}})
(defmacro checkins-query-card [query]
`(checkins-query-card* (mt/$ids ~'checkins ~query)))
(defn- venues-query-card [aggregation-op]
{:name card-name
:dataset_query {:database (mt/id)
:type :query
:query {:source-table (mt/id :venues)
:aggregation [[aggregation-op (mt/id :venues :price)]]}}})
(defn- rasta-id []
(users/user->id :rasta))
(mt/user->id :rasta))
(defn- realize-lazy-seqs
"It's possible when data structures contain lazy sequences that the database will be torn down before the lazy seq
......@@ -53,24 +57,124 @@
[data]
(walk/postwalk identity data))
(defn- pulse-test-fixture
(defn- do-with-site-url
[f]
(tu/with-temporary-setting-values [site-url "https://metabase.com/testmb"]
(mt/with-temporary-setting-values [site-url "https://metabase.com/testmb"]
(f)))
(defmacro ^:private slack-test-setup
"Macro that ensures test-data is present and disables sending of all notifications"
[& body]
`(with-redefs [metabase.pulse/send-notifications! realize-lazy-seqs
slack/channels-list (constantly [{:name "metabase_files"
:id "FOO"}])]
(pulse-test-fixture (fn [] ~@body))))
slack/files-channel (constantly {:name "metabase_files"
:id "FOO"})]
(do-with-site-url (fn [] ~@body))))
(defmacro ^:private email-test-setup
"Macro that ensures test-data is present and will use a fake inbox for emails"
[& body]
`(et/with-fake-inbox
(pulse-test-fixture (fn [] ~@body))))
`(mt/with-fake-inbox
(do-with-site-url (fn [] ~@body))))
(defn- do-with-pulse-for-card
"Creates a Pulse and other relevant rows for a `card` (using `pulse` and `pulse-card` properties if specfied), then
invokes
(f pulse)"
[{:keys [pulse pulse-card channel card]
:or {channel :email}}
f]
(mt/with-temp* [Pulse [{pulse-id :id, :as pulse} (merge {:name "Pulse Name"} pulse)]
PulseCard [_ (merge {:pulse_id pulse-id
:card_id (u/get-id card)
:position 0}
pulse-card)]
PulseChannel [{pc-id :id} (case channel
:email
{:pulse_id pulse-id}
:slack
{:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}})]]
(if (= channel :email)
(mt/with-temp PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]
(f pulse))
(f pulse))))
(defmacro ^:private with-pulse-for-card
"e.g.
(with-pulse-for-card [pulse {:card my-card, :pulse pulse-properties, ...}]
...)"
[[pulse-binding properties] & body]
`(do-with-pulse-for-card ~properties (fn [~pulse-binding] ~@body)))
(defn- do-test
"Run a single Pulse test with a standard set of boilerplate. Creates Card, Pulse, and other related objects using
`card`, `pulse`, and `pulse-card` properties, then sends the Pulse; finally, test assertions in `assert` are
invoked. `assert` can contain `:email` and/or `:slack` assertions, which are used to test an email and Slack version
of that Pulse respectively. `:assert` functions have the signature
(f object-ids send-pulse!-response)
Example:
(do-test
{:card {:dataset_query (mt/mbql-query checkins)}
:assert {:slack (fn [{:keys [pulse-id]} response]
(is (= {:sent pulse-id}
response)))}})"
[{:keys [card pulse pulse-card fixture], assertions :assert}]
{:pre [(map? assertions) ((some-fn :email :slack) assertions)]}
(doseq [channel-type [:email :slack]
:let [f (get assertions channel-type)]
:when f]
(assert (fn? f))
(testing (format "sent to %s channel" channel-type)
(mt/with-temp Card [{card-id :id} (merge {:name card-name} card)]
(with-pulse-for-card [{pulse-id :id} {:card card-id, :pulse pulse, :pulse-card pulse-card, :channel channel-type}]
(letfn [(thunk* []
(f {:card-id card-id, :pulse-id pulse-id}
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))))
(thunk []
(if fixture
(fixture {:card-id card-id, :pulse-id pulse-id} thunk*)
(thunk*)))]
(case channel-type
:email (email-test-setup (thunk))
:slack (slack-test-setup (thunk)))))))))
(defn- tests
"Convenience for writing multiple tests using `do-test`. `common` is a map of shared properties as passed to `do-test`
that is deeply merged with the individual maps for each test. Other args are alternating `testing` context messages
and properties as passed to `do-test`:
(tests
;; shared properties used for both tests
{:card {:dataset_query (mt/mbql-query)}}
\"Test 1\"
{:assert {:email (fn [_ _] (is ...))}}
\"Test 2\"
;; override just the :display property of the Card
{:card {:display \"table\"}
:assert {:email (fn [_ _] (is ...))}})"
{:style/indent 1}
[common & {:as message->m}]
(doseq [[message m] message->m]
(testing message
(do-test (merge-with merge common m)))))
(defn- force-bytes-thunk
"Grabs the thunk that produces the image byte array and invokes it"
[results]
((-> results
:attachments
first
:attachment-bytes-thunk)))
(def ^:private png-attachment
{:type :inline
......@@ -79,7 +183,7 @@
:content java.net.URL})
(defn- rasta-pulse-email [& [email]]
(et/email-to :rasta (merge {:subject "Pulse: Pulse Name",
(mt/email-to :rasta (merge {:subject "Pulse: Pulse Name",
:body [{"Pulse Name" true}
png-attachment]}
email)))
......@@ -100,346 +204,14 @@
:description "More results for 'Test card'"
:content-id false})
(deftest basic-test
(testing "Basic test, 1 card, 1 recipient"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (rasta-pulse-email)
(et/summarize-multipart-email #"Pulse Name"))))))
(testing "Basic test, 1 card, 1 recipient, 19 results, so no attachment"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil
:limit 19})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (rasta-pulse-email {:body [{"Pulse Name" true
"More results have been included" false
"ID</th>" true}]})
(et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>"))))))
(testing "Basic test, 1 card, 1 recipient, 21 results results in a CSV being attached and a table being sent"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil
:limit 21})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (rasta-pulse-email {:body [{"Pulse Name" true
"More results have been included" true
"ID</th>" true}
csv-attachment]})
(et/summarize-multipart-email #"Pulse Name" #"More results have been included" #"ID</th>")))))))
(deftest ensure-constraints-test
(testing "Validate pulse queries are limited by `default-query-constraints`"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:aggregation nil})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(with-redefs [constraints/default-query-constraints {:max-results 10000
:max-results-bare-rows 30}]
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(let [first-message (-> @et/inbox vals ffirst)]
(is (= true
(some? first-message))
"Should have a message in the inbox")
(when first-message
(let [filename (-> first-message :body last :content)
exists? (some-> filename io/file .exists)]
(is (= true
exists?)
"File should exist")
(testing (str "tmp file = %s" filename)
(testing "Slurp in the generated CSV and count the lines found in the file"
(when exists?
(is (= 31
(-> (slurp filename) str/split-lines count))
"Should return 30 results (the redef'd limit) plus the header row"))))))))))))
(deftest multiple-recipients-test
(testing "Pulse should be sent to two recipients"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]
PulseChannelRecipient [_ {:user_id (users/user->id :crowberto)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (into {} (map (fn [user-kwd]
(et/email-to user-kwd {:subject "Pulse: Pulse Name",
:to #{"rasta@metabase.com" "crowberto@metabase.com"}
:body [{"Pulse Name" true}
png-attachment]}))
[:rasta :crowberto]))
(et/summarize-multipart-email #"Pulse Name")))))))
(deftest two-cards-in-one-pulse-test
(testing "1 pulse that has 2 cards, should contain two attachments"
(tt/with-temp* [Card [{card-id-1 :id} (assoc (checkins-query (mt/$ids checkins {:breakout [!hour.date]}))
:name "card 1")]
Card [{card-id-2 :id} (assoc (checkins-query (mt/$ids checkins {:breakout [!month.date]}))
:name "card 2")]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-1
:position 0}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-2
:position 1}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (rasta-pulse-email {:body [{"Pulse Name" true}
png-attachment
png-attachment]})
(et/summarize-multipart-email #"Pulse Name")))))))
(deftest empty-results-test
(testing "Pulse where the card has no results, but skip_if_empty is false, so should still send"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:filter [">",["field-id" (data/id :checkins :date)],"2017-10-24"]
:breakout [["datetime-field" ["field-id" (data/id :checkins :date)] "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [pulse-card {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= (rasta-pulse-email)
(et/summarize-multipart-email #"Pulse Name")))))))
(deftest empty-results-skip-if-empty-test
(testing "Pulse where the card has no results, skip_if_empty is true, so no pulse should be sent"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:filter [">",["field-id" (data/id :checkins :date)],"2017-10-24"]
:breakout [["datetime-field" ["field-id" (data/id :checkins :date)] "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty true}]
PulseCard [pulse-card {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(is (= {}
@et/inbox))))))
(deftest rows-alert-no-data-test
(testing "Rows alert with no data"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:filter [">",["field-id" (data/id :checkins :date)],"2017-10-24"]
:breakout [["datetime-field" ["field-id" (data/id :checkins :date)] "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [pulse-card {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= {}
@et/inbox))))))
(defn- rasta-alert-email
[subject email-body]
(et/email-to :rasta {:subject subject
(mt/email-to :rasta {:subject subject
:body email-body}))
(def ^:private test-card-result {card-name true})
(def ^:private test-card-regex (re-pattern card-name))
(deftest alert-with-data-test
(testing "Rows alert with data"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[(assoc test-card-result "More results have been included" false), png-attachment])
(et/summarize-multipart-email test-card-regex #"More results have been included")))))))
(deftest rows-alert-with-too-much-data-test
(testing "Rows alert with too much data will attach as CSV and include a table"
(tt/with-temp* [Card [{card-id :id} (checkins-query {:limit 21
:aggregation nil})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[(merge test-card-result
{"More results have been included" true
"ID</th>" true}),
csv-attachment])
(et/summarize-multipart-email test-card-regex #"More results have been included" #"ID</th>")))))))
(deftest above-goal-alert-with-data-test
(testing "Above goal alert with data"
(tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-04-01" "2014-06-01"]
:breakout [["datetime-field" (data/id :checkins :date) "day"]]})
{:display :line
:visualization_settings {:graph.show_goal true :graph.goal_value 5.9}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= (rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result, png-attachment])
(et/summarize-multipart-email test-card-regex)))))))
(deftest native-query-with-user-specified-axes-test
(testing "Native query with user-specified x and y axis"
(tt/with-temp* [Card [{card-id :id} {:name "Test card"
:dataset_query {:database (data/id)
:type :native
:native {:query (str "select count(*) as total_per_day, date as the_day "
"from checkins "
"group by date")}}
:display :line
:visualization_settings {:graph.show_goal true
:graph.goal_value 5.9
:graph.dimensions ["the_day"]
:graph.metrics ["total_per_day"]}}]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= (rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result png-attachment])
(et/summarize-multipart-email test-card-regex)))))))
;; Above goal alert, with no data above goal
(expect
{}
(tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-02-01" "2014-04-01"]
:breakout [["datetime-field" (data/id :checkins :date) "day"]]})
{:display :area
:visualization_settings {:graph.show_goal true :graph.goal_value 5.9}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
@et/inbox)))
;; Below goal alert with no satisfying data
(expect
{}
(tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-02-10" "2014-02-12"]
:breakout [["datetime-field" (data/id :checkins :date) "day"]]})
{:display :bar
:visualization_settings {:graph.show_goal true :graph.goal_value 1.1}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
@et/inbox)))
;; Below goal alert with data
(expect
(rasta-alert-email "Metabase alert: Test card has gone below its goal"
[test-card-result, png-attachment])
(tt/with-temp* [Card [{card-id :id} (merge (checkins-query {:filter ["between",["field-id" (data/id :checkins :date)],"2014-02-12" "2014-02-17"]
:breakout [["datetime-field" (data/id :checkins :date) "day"]]})
{:display :line
:visualization_settings {:graph.show_goal true :graph.goal_value 1.1}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex))))
(defn- thunk->boolean [{:keys [attachments] :as result}]
(assoc result :attachments (for [attachment-info attachments]
(update attachment-info :attachment-bytes-thunk fn?))))
......@@ -449,8 +221,8 @@
(^:private output [_]))
(defn- invoke-with-wrapping
"Apply `args` to `func`, capturing the arguments of the invocation and the result of the invocation. Store the arguments in
`input-atom` and the result in `output-atom`."
"Apply `args` to `func`, capturing the arguments of the invocation and the result of the invocation. Store the
arguments in `input-atom` and the result in `output-atom`."
[input-atom output-atom func args]
(swap! input-atom conj args)
(let [result (apply func args)]
......@@ -480,110 +252,8 @@
(invoke [_ x1 x2 x3 x4 x5 x6]
(invoke-with-wrapping input output func [x1 x2 x3 x4 x5 x6])))))
;; Basic slack test, 1 card, 1 recipient channel
(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]]
{:channel-id "#general",
:message "Pulse: Pulse Name",
:attachments
[{:title card-name,
:attachment-bytes-thunk true,
:title_link (str "https://metabase.com/testmb/question/" card-id),
:attachment-name "image.png",
:channel-id "FOO",
:fallback card-name}]}
(slack-test-setup
(-> (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
first
thunk->boolean)))
(defn- force-bytes-thunk
"Grabs the thunk that produces the image byte array and invokes it"
[results]
((-> results
:attachments
first
:attachment-bytes-thunk)))
;; Basic slack test, 1 card, 1 recipient channel, verifies that "more results in attachment" text is not present for
;; slack pulses
(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:aggregation nil
:limit 25})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]]
[{:channel-id "#general",
:message "Pulse: Pulse Name",
:attachments
[{:title card-name,
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id),
:attachment-name "image.png",
:channel-id "FOO",
:fallback card-name}]}
1 ;; -> attached-results-text should be invoked exactly once
[nil] ;; -> attached-results-text should return nil since it's a slack message
]
(slack-test-setup
(with-redefs [render.body/attached-results-text (wrap-function (var-get #'render.body/attached-results-text))]
(let [[pulse-results] (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))]
;; If we don't force the thunk, the rendering code will never execute and attached-results-text won't be called
(force-bytes-thunk pulse-results)
[(thunk->boolean pulse-results)
(count (input (var-get #'render.body/attached-results-text)))
(output (var-get #'render.body/attached-results-text))]))))
(defn- produces-bytes? [{:keys [attachment-bytes-thunk]}]
(< 0 (alength ^bytes (attachment-bytes-thunk))))
;; Basic slack test, 2 cards, 1 recipient channel
(tt/expect-with-temp [Card [{card-id-1 :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Card [{card-id-2 :id} (-> {:breakout [["datetime-field" (data/id :checkins :date) "minute"]]}
checkins-query
(assoc :name "Test card 2"))]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-1
:position 0}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-2
:position 1}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]]
[{:channel-id "#general",
:message "Pulse: Pulse Name",
:attachments
[{:title card-name,
:attachment-bytes-thunk true,
:title_link (str "https://metabase.com/testmb/question/" card-id-1),
:attachment-name "image.png",
:channel-id "FOO",
:fallback card-name}
{:title "Test card 2",
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id-2),
:attachment-name "image.png",
:channel-id "FOO",
:fallback "Test card 2"}]}
true]
(slack-test-setup
(let [[slack-data] (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))]
[(thunk->boolean slack-data)
(every? produces-bytes? (:attachments slack-data))])))
(pos? (alength ^bytes (attachment-bytes-thunk))))
(defn- email-body? [{message-type :type, ^String content :content}]
(and (= "text/html; charset=utf-8" message-type)
......@@ -595,311 +265,558 @@
(= "image/png" content-type)
(instance? java.net.URL content)))
;; Test with a slack channel and an email
(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id-1 :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]
PulseChannel [{pc-id-2 :id} {:pulse_id pulse-id
:channel_type "email"
:details {}}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id-2}]]
[{:channel-id "#general",
:message "Pulse: Pulse Name",
:attachments [{:title card-name, :attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id),
:attachment-name "image.png", :channel-id "FOO",
:fallback card-name}]}
true
{:subject "Pulse: Pulse Name",
:recipients ["rasta@metabase.com"],
:message-type :attachments}
2
true
true]
(slack-test-setup
(let [pulse-data (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
slack-data (m/find-first #(contains? % :channel-id) pulse-data)
email-data (m/find-first #(contains? % :subject) pulse-data)]
[(thunk->boolean slack-data)
(every? produces-bytes? (:attachments slack-data))
(select-keys email-data [:subject :recipients :message-type])
(count (:message email-data))
(email-body? (first (:message email-data)))
(attachment? (second (:message email-data)))])))
;; Rows slack alert with data
(tt/expect-with-temp [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]]
[{:channel-id "#general",
:message "Alert: Test card",
:attachments [{:title card-name, :attachment-bytes-thunk true,
:title_link (str "https://metabase.com/testmb/question/" card-id)
:attachment-name "image.png", :channel-id "FOO",
:fallback card-name}]}
true]
(slack-test-setup
(let [[result] (pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))]
[(thunk->boolean result)
(every? produces-bytes? (:attachments result))])))
(defn- venues-query [aggregation-op]
{:name card-name
:dataset_query {:database (data/id)
:type :query
:query {:source-table (data/id :venues)
:aggregation [[aggregation-op (data/id :venues :price)]]}}})
;; Above goal alert with a progress bar
(expect
(rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result])
(tt/with-temp* [Card [{card-id :id} (merge (venues-query "max")
{:display :progress
:visualization_settings {:progress.goal 3}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex))))
;; Below goal alert with progress bar
(expect
(rasta-alert-email "Metabase alert: Test card has gone below its goal"
[test-card-result])
(tt/with-temp* [Card [{card-id :id} (merge (venues-query "min")
{:display :progress
:visualization_settings {:progress.goal 2}})]
Pulse [{pulse-id :id} {:alert_condition "goal"
:alert_first_only false
:alert_above_goal false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex))))
;; Rows alert, first run only with data
(expect
(rasta-alert-email "Metabase alert: Test card has results"
[(assoc test-card-result "stop sending you alerts" true)
png-attachment])
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only true}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex
#"stop sending you alerts"))))
;; First run alert with no data
(expect
[{} true]
(tt/with-temp* [Card [{card-id :id} (checkins-query {:filter [">",["field-id" (data/id :checkins :date)],"2017-10-24"]
:breakout [["datetime-field" ["field-id" (data/id :checkins :date)] "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only true}]
PulseCard [pulse-card {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
[@et/inbox
(db/exists? Pulse :id pulse-id)])))
(defn- add-rasta-attachment
"Append `ATTACHMENT` to the first email found for Rasta"
"Append `attachment` to the first email found for Rasta"
[email attachment]
(update-in email ["rasta@metabase.com" 0] #(update % :body conj attachment)))
;; Basic test, 1 card, 1 recipient, with CSV attachment
(expect
(add-rasta-attachment (rasta-pulse-email) csv-attachment)
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_csv true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(et/summarize-multipart-email #"Pulse Name"))))
;; Basic alert test, 1 card, 1 recipient, with CSV attachment
(expect
(rasta-alert-email "Metabase alert: Test card has results"
[test-card-result, png-attachment, csv-attachment])
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_csv true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex))))
;; With a "rows" type of pulse (table visualization) we should include the CSV by default
(expect
(-> (rasta-pulse-email)
;; There's no PNG with a table visualization, remove it from the expected results
(update-in ["rasta@metabase.com" 0 :body] (comp vector first))
(add-rasta-attachment csv-attachment))
(tt/with-temp* [Card [{card-id :id} {:name card-name
:dataset_query {:database (data/id)
:type :query
:query {:source-table (data/id :checkins)}}}]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(et/summarize-multipart-email #"Pulse Name"))))
;; If the pulse is already configured to send an XLS, no need to include a CSV
(expect
(-> (rasta-pulse-email)
;; There's no PNG with a table visualization, remove it from the expected results
(update-in ["rasta@metabase.com" 0 :body] (comp vector first))
(add-rasta-attachment xls-attachment))
(tt/with-temp* [Card [{card-id :id} {:name card-name
:dataset_query {:database (data/id)
:type :query
:query {:source-table (data/id :checkins)}}}]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_xls true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(et/summarize-multipart-email #"Pulse Name"))))
;; Basic test of card with CSV and XLS attachments, but no data. Should not include an attachment
(expect
(rasta-pulse-email)
(tt/with-temp* [Card [{card-id :id} (checkins-query {:filter [">",["field-id" (data/id :checkins :date)],"2017-10-24"]
:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_csv true
:include_xls true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(et/summarize-multipart-email #"Pulse Name"))))
;; Basic test, 1 card, 1 recipient, with XLS attachment
(expect
(add-rasta-attachment (rasta-pulse-email) xls-attachment)
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_xls true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
(et/summarize-multipart-email #"Pulse Name"))))
;; Rows alert with data and a CSV + XLS attachment
(expect
(rasta-alert-email "Metabase alert: Test card has results"
[test-card-result, png-attachment, csv-attachment, xls-attachment])
(tt/with-temp* [Card [{card-id :id} (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id
:position 0
:include_csv true
:include_xls true}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id}]
PulseChannelRecipient [_ {:user_id (rasta-id)
:pulse_channel_id pc-id}]]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(et/summarize-multipart-email test-card-regex))))
;; even if Card is saved as `:async?` we shouldn't run the query async
(tu/expect-schema
{:card (s/pred map?)
:result (s/pred map?)}
(tt/with-temp Card [card {:dataset_query {:database (data/id)
:type :query
:query {:source-table (data/id :venues)}
:async? true}}]
(pulse/execute-card {} card)))
;;; +----------------------------------------------------------------------------------------------------------------+
;;; | Tests |
;;; +----------------------------------------------------------------------------------------------------------------+
(deftest basic-timeseries-test
(do-test
{:card (checkins-query-card {:breakout [!hour.date]})
:pulse {:skip_if_empty false}
:assert
{:email
(fn [_ _]
(is (= (rasta-pulse-email)
(mt/summarize-multipart-email #"Pulse Name"))))
:slack
(fn [{:keys [card-id]} [pulse-results]]
(is (= {:channel-id "#general"
:message "Pulse: Pulse Name"
:attachments
[{:title card-name
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id)
:attachment-name "image.png"
:channel-id "FOO"
:fallback card-name}]}
(thunk->boolean pulse-results))))}}))
(deftest basic-table-test
(tests {:pulse {:skip_if_empty false}}
"19 results, so no attachment"
{:card (checkins-query-card {:aggregation nil, :limit 19})
:fixture
(fn [_ thunk]
(with-redefs [render.body/attached-results-text (wrap-function @#'render.body/attached-results-text)]
(thunk)))
:assert
{:email
(fn [_ _]
(is (= (rasta-pulse-email {:body [{"Pulse Name" true
"More results have been included" false
"ID</th>" true}]})
(mt/summarize-multipart-email
#"Pulse Name"
#"More results have been included" #"ID</th>"))))
:slack
(fn [{:keys [card-id]} [pulse-results]]
;; If we don't force the thunk, the rendering code will never execute and attached-results-text won't be
;; called
(force-bytes-thunk pulse-results)
(testing "\"more results in attachment\" text should not be present for Slack Pulses"
(testing "Pulse results"
(is (= {:channel-id "#general"
:message "Pulse: Pulse Name"
:attachments
[{:title card-name
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id)
:attachment-name "image.png"
:channel-id "FOO"
:fallback card-name}]}
(thunk->boolean pulse-results))))
(testing "attached-results-text should be invoked exactly once"
(is (= 1
(count (input @#'render.body/attached-results-text)))))
(testing "attached-results-text should return nil since it's a slack message"
(is (= [nil]
(output @#'render.body/attached-results-text))))))}}
"21 results results in a CSV being attached and a table being sent"
{:card (checkins-query-card {:aggregation nil, :limit 21})
:assert
{:email
(fn [_ _]
(is (= (rasta-pulse-email {:body [{"Pulse Name" true
"More results have been included" true
"ID</th>" true}
csv-attachment]})
(mt/summarize-multipart-email
#"Pulse Name"
#"More results have been included" #"ID</th>"))))}}))
(deftest csv-test
(tests {:pulse {:skip_if_empty false}
:card (checkins-query-card {:breakout [!hour.date]})})
"1 card, 1 recipient, with CSV attachment"
{:assert
{:email
(fn [_ _]
(is (= (add-rasta-attachment (rasta-pulse-email) csv-attachment)
(mt/summarize-multipart-email #"Pulse Name"))))}}
"alert with a CSV"
{:pulse-card {:include_csv true}
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[test-card-result, png-attachment, csv-attachment])
(mt/summarize-multipart-email test-card-regex))))}}
"With a \"rows\" type of pulse (table visualization) we should include the CSV by default"
{:card {:dataset_query (mt/mbql-query checkins)}
:assert
{:email
(fn [_ _]
(is (= (-> (rasta-pulse-email)
;; There's no PNG with a table visualization, remove it from the assert results
(update-in ["rasta@metabase.com" 0 :body] (comp vector first))
(add-rasta-attachment csv-attachment))
(mt/summarize-multipart-email #"Pulse Name"))))}})
(deftest xls-test
(testing "If the pulse is already configured to send an XLS, no need to include a CSV"
(do-test
{:card {:dataset_query (mt/mbql-query checkins)}
:pulse-card {:include_xls true}
:assert
{:email
(fn [_ _]
(is (= (-> (rasta-pulse-email)
;; There's no PNG with a table visualization, remove it from the assert results
(update-in ["rasta@metabase.com" 0 :body] (comp vector first))
(add-rasta-attachment xls-attachment))
(mt/summarize-multipart-email #"Pulse Name"))))}})))
;; Not really sure how this is significantly different from `xls-test`
(deftest xls-test-2
(testing "Basic test, 1 card, 1 recipient, with XLS attachment"
(do-test
{:card (checkins-query-card {:breakout [!hour.date]})
:pulse-card {:include_xls true}
:assert
{:email
(fn [_ _]
(is (= (add-rasta-attachment (rasta-pulse-email) xls-attachment)
(mt/summarize-multipart-email #"Pulse Name"))))}})))
(deftest csv-xls-no-data-test
(testing "card with CSV and XLS attachments, but no data. Should not include an attachment"
(do-test
{:card (checkins-query-card {:filter [:> $date "2017-10-24"]
:breakout [!hour.date]})
:pulse {:skip_if_empty false}
:pulse-card {:include_csv true
:include_xls true}
:assert
{:email
(fn [_ _]
(is (= (rasta-pulse-email)
(mt/summarize-multipart-email #"Pulse Name"))))}})))
(deftest ensure-constraints-test
(testing "Validate pulse queries are limited by `default-query-constraints`"
(do-test
{:card
(checkins-query-card {:aggregation nil})
:fixture
(fn [_ thunk]
(with-redefs [constraints/default-query-constraints {:max-results 10000
:max-results-bare-rows 30}]
(thunk)))
:assert
{:email
(fn [_ _]
(let [first-message (-> @mt/inbox vals ffirst)]
(is (= true
(some? first-message))
"Should have a message in the inbox")
(when first-message
(let [filename (-> first-message :body last :content)
exists? (some-> filename io/file .exists)]
(testing "File should exist"
(is (= true
exists?)))
(testing (str "tmp file = %s" filename)
(testing "Slurp in the generated CSV and count the lines found in the file"
(when exists?
(testing "Should return 30 results (the redef'd limit) plus the header row"
(is (= 31
(-> (slurp filename) str/split-lines count))
)))))))))}})))
(deftest multiple-recipients-test
(testing "Pulse should be sent to two recipients"
(do-test
{:card
(checkins-query-card {:breakout [!hour.date]})
:fixture
(fn [{:keys [pulse-id]} thunk]
(mt/with-temp PulseChannelRecipient [_ {:user_id (mt/user->id :crowberto)
:pulse_channel_id (db/select-one-id PulseChannel :pulse_id pulse-id)}]
(thunk)))
:assert
{:email
(fn [_ _]
(is (= (into {} (map (fn [user-kwd]
(mt/email-to user-kwd {:subject "Pulse: Pulse Name",
:to #{"rasta@metabase.com" "crowberto@metabase.com"}
:body [{"Pulse Name" true}
png-attachment]}))
[:rasta :crowberto]))
(mt/summarize-multipart-email #"Pulse Name"))))}})))
(deftest two-cards-in-one-pulse-test
(testing "1 pulse that has 2 cards, should contain two attachments"
(do-test
{:card
(assoc (checkins-query-card {:breakout [!hour.date]}) :name "card 1")
:fixture
(fn [{:keys [pulse-id]} thunk]
(mt/with-temp* [Card [{card-id-2 :id} (assoc (checkins-query-card {:breakout [!month.date]})
:name "card 2")]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-2
:position 1}]]
(thunk)))
:assert
{:email
(fn [_ _]
(is (= (rasta-pulse-email {:body [{"Pulse Name" true}
png-attachment
png-attachment]})
(mt/summarize-multipart-email #"Pulse Name"))))}})))
(deftest empty-results-test
(testing "Pulse where the card has no results"
(tests {:card (checkins-query-card {:filter [:> $date "2017-10-24"]
:breakout [!hour.date]})}
"skip if empty = false"
{:pulse {:skip_if_empty false}
:assert {:email (fn [_ _]
(is (= (rasta-pulse-email)
(mt/summarize-multipart-email #"Pulse Name"))))}}
"skip if empty = true"
{:pulse {:skip_if_empty true}
:assert {:email (fn [_ _]
(is (= {}
(mt/summarize-multipart-email #"Pulse Name"))))}})))
(deftest rows-alert-test
(testing "Rows alert"
(tests {:pulse {:alert_condition "rows", :alert_first_only false}}
"with data"
{:card
(checkins-query-card {:breakout [!hour.date]})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email
"Metabase alert: Test card has results"
[(assoc test-card-result "More results have been included" false)
png-attachment])
(mt/summarize-multipart-email test-card-regex #"More results have been included"))))
:slack
(fn [{:keys [card-id]} [result]]
(is (= {:channel-id "#general",
:message "Alert: Test card",
:attachments [{:title card-name
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id)
:attachment-name "image.png"
:channel-id "FOO"
:fallback card-name}]}
(thunk->boolean result)))
(is (every? produces-bytes? (:attachments result))))}}
"with no data"
{:card
(checkins-query-card {:filter [:> $date "2017-10-24"]
:breakout [!hour.date]})
:assert
{:email
(fn [_ _]
(is (= {}
@mt/inbox)))}}
"too much data"
{:card
(checkins-query-card {:limit 21, :aggregation nil})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[(merge test-card-result
{"More results have been included" true
"ID</th>" true})
csv-attachment])
(mt/summarize-multipart-email test-card-regex
#"More results have been included"
#"ID</th>"))))}}
"with data and a CSV + XLS attachment"
{:card (checkins-query-card {:breakout [!hour.date]})
:pulse-card {:include_csv true, :include_xls true}
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[test-card-result png-attachment csv-attachment xls-attachment])
(mt/summarize-multipart-email test-card-regex))))}})))
(deftest alert-first-run-only-test
(tests {:pulse {:alert_condition "rows", :alert_first_only true}}
"first run only with data"
{:card
(checkins-query-card {:breakout [!hour.date]})
:assert
{:email
(fn [{:keys [pulse-id]} _]
(is (= (rasta-alert-email "Metabase alert: Test card has results"
[(assoc test-card-result "stop sending you alerts" true)
png-attachment])
(mt/summarize-multipart-email test-card-regex #"stop sending you alerts")))
(testing "Pulse should be deleted"
(is (= false
(db/exists? Pulse :id pulse-id)))))}}
"first run alert with no data"
{:card
(checkins-query-card {:filter [:> $date "2017-10-24"]
:breakout [!hour.date]})
:assert
{:email
(fn [{:keys [pulse-id]} _]
(is (= {}
@mt/inbox))
(testing "Pulse should still exist"
(is (= true
(db/exists? Pulse :id pulse-id)))))}}))
(deftest above-goal-alert-test
(testing "above goal alert"
(tests {:pulse {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}}
"with data"
{:card
(merge (checkins-query-card {:filter [:between $date "2014-04-01" "2014-06-01"]
:breakout [!day.date]})
{:display :line
:visualization_settings {:graph.show_goal true :graph.goal_value 5.9}})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result, png-attachment])
(mt/summarize-multipart-email test-card-regex))))}}
"no data"
{:card
(merge (checkins-query-card {:filter [:between $date "2014-02-01" "2014-04-01"]
:breakout [!day.date]})
{:display :area
:visualization_settings {:graph.show_goal true :graph.goal_value 5.9}})
:assert
{:email
(fn [_ _]
(is (= {}
@mt/inbox)))}}
"with progress bar"
{:card
(merge (venues-query-card "max")
{:display :progress
:visualization_settings {:progress.goal 3}})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result])
(mt/summarize-multipart-email test-card-regex))))}})))
(deftest below-goal-alert-test
(testing "Below goal alert"
(tests {:card {:display :bar
:visualization_settings {:graph.show_goal true :graph.goal_value 1.1}}
:pulse {:alert_condition "goal"
:alert_first_only false
:alert_above_goal false}}
"with data"
{:card
(checkins-query-card {:filter [:between $date "2014-02-12" "2014-02-17"]
:breakout [!day.date]})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has gone below its goal"
[test-card-result png-attachment])
(mt/summarize-multipart-email test-card-regex))))}}
"with no satisfying data"
{:card
(checkins-query-card {:filter [:between $date "2014-02-10" "2014-02-12"]
:breakout [!day.date]})
:assert
{:email
(fn [_ _]
(is (= {}
@mt/inbox)))}}
"with progress bar"
{:card
(merge (venues-query-card "min")
{:display :progress
:visualization_settings {:progress.goal 2}})
:assert
{:email
(fn [_ _]
(is (= (rasta-alert-email "Metabase alert: Test card has gone below its goal"
[test-card-result])
(mt/summarize-multipart-email test-card-regex))))}})))
(deftest native-query-with-user-specified-axes-test
(testing "Native query with user-specified x and y axis"
(mt/with-temp Card [{card-id :id} {:name "Test card"
:dataset_query {:database (mt/id)
:type :native
:native {:query (str "select count(*) as total_per_day, date as the_day "
"from checkins "
"group by date")}}
:display :line
:visualization_settings {:graph.show_goal true
:graph.goal_value 5.9
:graph.dimensions ["the_day"]
:graph.metrics ["total_per_day"]}}]
(with-pulse-for-card [{pulse-id :id} {:card card-id, :pulse {:alert_condition "goal"
:alert_first_only false
:alert_above_goal true}}]
(email-test-setup
(pulse/send-pulse! (models.pulse/retrieve-notification pulse-id))
(is (= (rasta-alert-email "Metabase alert: Test card has reached its goal"
[test-card-result png-attachment])
(mt/summarize-multipart-email test-card-regex))))))))
(deftest basic-slack-test-2
(testing "Basic slack test, 2 cards, 1 recipient channel"
(mt/with-temp* [Card [{card-id-1 :id} (checkins-query-card {:breakout [!hour.date]})]
Card [{card-id-2 :id} (-> {:breakout [[:datetime-field (mt/id :checkins :date) "minute"]]}
checkins-query-card
(assoc :name "Test card 2"))]
Pulse [{pulse-id :id} {:name "Pulse Name"
:skip_if_empty false}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-1
:position 0}]
PulseCard [_ {:pulse_id pulse-id
:card_id card-id-2
:position 1}]
PulseChannel [{pc-id :id} {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]]
(slack-test-setup
(let [[slack-data] (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))]
(is (= {:channel-id "#general",
:message "Pulse: Pulse Name",
:attachments
[{:title card-name,
:attachment-bytes-thunk true,
:title_link (str "https://metabase.com/testmb/question/" card-id-1),
:attachment-name "image.png",
:channel-id "FOO",
:fallback card-name}
{:title "Test card 2",
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id-2),
:attachment-name "image.png",
:channel-id "FOO",
:fallback "Test card 2"}]}
(thunk->boolean slack-data)))
(testing "attachments"
(is (= true
(every? produces-bytes? (:attachments slack-data))))))))))
(deftest multi-channel-test
(testing "Test with a slack channel and an email"
(mt/with-temp Card [{card-id :id} (checkins-query-card {:breakout [!hour.date]})]
;; create a Pulse with an email channel
(with-pulse-for-card [{pulse-id :id} {:card card-id, :pulse {:skip_if_empty false}}]
;; add additional Slack channel
(mt/with-temp PulseChannel [_ {:pulse_id pulse-id
:channel_type "slack"
:details {:channel "#general"}}]
(slack-test-setup
(let [pulse-data (pulse/send-pulse! (models.pulse/retrieve-pulse pulse-id))
slack-data (m/find-first #(contains? % :channel-id) pulse-data)
email-data (m/find-first #(contains? % :subject) pulse-data)]
(is (= {:channel-id "#general"
:message "Pulse: Pulse Name"
:attachments [{:title card-name
:attachment-bytes-thunk true
:title_link (str "https://metabase.com/testmb/question/" card-id)
:attachment-name "image.png"
:channel-id "FOO"
:fallback card-name}]}
(thunk->boolean slack-data)))
(is (every? produces-bytes? (:attachments slack-data)))
(is (= {:subject "Pulse: Pulse Name", :recipients ["rasta@metabase.com"], :message-type :attachments}
(select-keys email-data [:subject :recipients :message-type])))
(is (= 2
(count (:message email-data))))
(is (email-body? (first (:message email-data))))
(is (attachment? (second (:message email-data)))))))))))
(deftest dont-run-async-test
(testing "even if Card is saved as `:async?` we shouldn't run the query async"
(mt/with-temp Card [card {:dataset_query {:database (mt/id)
:type :query
:query {:source-table (mt/id :venues)}
:async? true}}]
(is (schema= {:card (s/pred map?)
:result (s/pred map?)}
(pulse/execute-card {} card))))))
(deftest pulse-permissions-test
(testing "Pulses should be sent with the Permissions of the user that created them."
(letfn [(send-pulse-created-by-user! [user-kw]
(tt/with-temp* [Collection [coll]
Card [card {:dataset_query (data/mbql-query checkins
(mt/with-temp* [Collection [coll]
Card [card {:dataset_query (mt/mbql-query checkins
{:order-by [[:asc $id]]
:limit 1})
:collection_id (:id coll)}]]
......
......@@ -3,7 +3,7 @@
[metabase
[email :as email]
[email-test :as et]
[pulse-test :refer [checkins-query]]]
[pulse-test :refer [checkins-query-card]]]
[metabase.models
[card :refer [Card]]
[pulse :refer [Pulse]]
......@@ -15,7 +15,7 @@
[metabase.test.data.users :as users]
[toucan.util.test :as tt]))
(tt/expect-with-temp [Card [{card-id :id} (assoc (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})
(tt/expect-with-temp [Card [{card-id :id} (assoc (checkins-query-card {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})
:name "My Question Name")]
Pulse [{pulse-id :id} {:alert_condition "rows"
:alert_first_only false}]
......@@ -49,7 +49,7 @@
:body {"Test Message" true}})
:exceptions []}
(tt/with-temp* [Card [{card-id :id} (assoc (checkins-query {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})
(tt/with-temp* [Card [{card-id :id} (assoc (checkins-query-card {:breakout [["datetime-field" (data/id :checkins :date) "hour"]]})
:name "My Question Name")]
Pulse [{pulse-id :id} {:name "Test", :archived true}]
PulseCard [_ {:pulse_id pulse-id
......
......@@ -708,13 +708,3 @@
:else
x))
(defmacro exception-and-message
"Invokes `body`, catches the exception and returns a map with the exception class, message and data"
[& body]
`(try
~@body
(catch Exception e#
{:ex-class (class e#)
:msg (.getMessage e#)
:data (ex-data e#)})))
{
"ok" : true,
"channel" : "C13372B6X",
"ts" : "1594847527.002800",
"message" : {
"type" : "message",
"subtype" : "bot_message",
"text" : ":wow:",
"ts" : "1594841337.002800",
"username" : "MetaBot",
"icons" : {
"image_48" : "https://s3-us-west-2.amazonaws.com/slack-files2/bot_icons/2016-01-19/18878021337_48.png"
},
"bot_id" : "B133781667P"
}
}
{
"ok" : true,
"file" : {
"ims" : [ ],
"thumb_80" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_80.png",
"thumb_360_h" : 152,
"channels" : [ "C94712B6X" ],
"editable" : false,
"is_external" : false,
"thumb_160" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_160.png",
"original_w" : 480,
"is_starred" : false,
"thumb_360_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_360.gif",
"url_private_download" : "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/download/wow.gif",
"name" : "wow.gif",
"permalink" : "https://metaboat.slack.com/files/U016YSX55QW/F017C3TSBK6/wow.gif",
"username" : "",
"mode" : "hosted",
"thumb_480_h" : 203,
"created" : 1594847102,
"display_as_bot" : false,
"thumb_480" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_480.png",
"deanimate_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_deanimate_gif.png",
"mimetype" : "image/gif",
"size" : 1929620,
"title" : "wow",
"is_public" : true,
"id" : "F017C3TSBK6",
"original_h" : 203,
"comments_count" : 0,
"external_type" : "",
"thumb_480_w" : 480,
"thumb_360_w" : 360,
"thumb_tiny" : "AwAUADDOJ5qXeViABOSeuaYkLycqufxp8kTJGC/BzwM0gJrZQR8zZ+ppLnK5Az+eadZsPMU4HAOfpSvGbn/VFSfQnFJDZBA+HzV0zKFJPaoBZSxgs5QYGcbuaV9u00CY2yOFJqo7tIxZjkmrdn9xvpVKmBJvZEKqcBhg09ZGEisvBJzxUTdBTx96P/PemgZtSjMLPnkKTWdAv2gM8hOd2MDgVpSf8er/AO4f5Vn2P+qb/fNSB//Z",
"public_url_shared" : false,
"thumb_360" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_360.png",
"groups" : [ ],
"filetype" : "gif",
"url_private" : "https://files.slack.com/files-pri/T078VLEET-F017C3TSBK6/wow.gif",
"pretty_type" : "GIF",
"has_rich_preview" : false,
"timestamp" : 1594847102,
"thumb_480_gif" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_480.gif",
"user" : "U016YSX55QW",
"thumb_64" : "https://files.slack.com/files-tmb/T078VLEET-F017C3TSBK6-4f9ea75cc9/wow_64.png",
"shares" : {
"public" : {
"C94712B6X" : [ {
"reply_users" : [ ],
"reply_users_count" : 0,
"reply_count" : 0,
"ts" : "1594847103.002500",
"channel_name" : "wow",
"team_id" : "T078VLEET"
} ]
}
},
"permalink_public" : "https://slack-files.com/T078VLEET-F017C3TSBK6-d9c051e26f"
},
"warning" : "superfluous_charset",
"response_metadata" : {
"warnings" : [ "superfluous_charset" ]
}
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment