From e6aded3d0be9524a8d352a7afaafbbc6a957cb3f Mon Sep 17 00:00:00 2001
From: Case Nelson <case@metabase.com>
Date: Mon, 25 Jul 2022 12:22:18 -0600
Subject: [PATCH] [Actions] Add emitter and action tests (#24199)

* wip

* Update kondo hook for with-temp*

`with-temp*` allows for unpaired bindings if you just want the default
model. This would break the `let` that the hook was binding if you had
an unpaired temp model followed by others.

* Adding tests for emitters and actions

Test hydration of :emitters

Test hydration of :action

Test http emitter execution

Test emitter crud

Test failure cases around emitter execution

Small consistency changes made to non-test code that shouldn't affect happy path FE code, largely 404 checking and keyword/string consistency.

* Fix unused requires

* Fix macro resolution
---
 .clj-kondo/config.edn                 |   4 +
 .clj-kondo/hooks/toucan/util/test.clj |   4 +-
 .lsp/config.edn                       |   2 +-
 src/metabase/actions/http_action.clj  |  64 +++++++++-------
 src/metabase/api/action.clj           |   6 +-
 src/metabase/api/emitter.clj          |  48 +-----------
 src/metabase/api/testing.clj          |   8 ++
 src/metabase/models/action.clj        |  38 +++++++---
 src/metabase/models/card.clj          |   3 +-
 test/metabase/actions/test_util.clj   | 102 +++++++++++++++++++-------
 test/metabase/api/emitter_test.clj    |  94 +++++++++++++++++++++---
 test/metabase/models/action_test.clj  |  34 +++++++++
 test/metabase/models/emitter_test.clj |  28 +++++++
 13 files changed, 306 insertions(+), 129 deletions(-)
 create mode 100644 test/metabase/models/action_test.clj
 create mode 100644 test/metabase/models/emitter_test.clj

diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn
index 6ba853341a9..0f85f88d20c 100644
--- a/.clj-kondo/config.edn
+++ b/.clj-kondo/config.edn
@@ -350,6 +350,10 @@
 
  :lint-as
  {metabase-enterprise.serialization.test-util/with-random-dump-dir      clojure.core/let
+  metabase.actions.test-util/with-query-action                          clojure.core/fn
+  metabase.actions.test-util/with-http-action                           clojure.core/fn
+  metabase.actions.test-util/with-card-emitter                          clojure.core/let
+  metabase.actions.test-util/with-dashboard-emitter                     clojure.core/let
   metabase.api.common/let-404                                           clojure.core/let
   metabase.db.data-migrations/defmigration                              clojure.core/def
   metabase.db.liquibase/with-liquibase                                  clojure.core/let
diff --git a/.clj-kondo/hooks/toucan/util/test.clj b/.clj-kondo/hooks/toucan/util/test.clj
index 2c4aca4ee92..abe0f89c78a 100644
--- a/.clj-kondo/hooks/toucan/util/test.clj
+++ b/.clj-kondo/hooks/toucan/util/test.clj
@@ -5,7 +5,9 @@
   (let [pairs       (partition 2 bindings)
         db-refs     (map first pairs)
         let-stream  (for [[_ binding+opts] pairs
-                          part             (:children binding+opts)]
+                          ;; if children is size 1 then ensure nil is on the right side of the let
+                          :let [[binding-part opts] (:children binding+opts)]
+                          part [binding-part opts]]
                       part)]
     (api/vector-node [(api/vector-node db-refs)
                       (api/list-node (list* (api/token-node `let)
diff --git a/.lsp/config.edn b/.lsp/config.edn
index 06fda82f6da..52ba8320eec 100644
--- a/.lsp/config.edn
+++ b/.lsp/config.edn
@@ -2,7 +2,7 @@
  :show-docs-arity-on-same-line? true
  :project-specs                 [{:project-path "deps.edn"
                                   :classpath-cmd ["clojure" "-A:dev:ee:ee-dev:drivers:drivers-dev" "-Spath"]}]
-
+ :clean {:ns-inner-blocks-indentation :keep}
  :linters {:clojure-lsp/unused-public-var {:level :warning
                                            :exclude-when-defined-by
                                            #{metabase.api.common/defendpoint}}}}
diff --git a/src/metabase/actions/http_action.clj b/src/metabase/actions/http_action.clj
index c9e98b4cce3..365e394e55f 100644
--- a/src/metabase/actions/http_action.clj
+++ b/src/metabase/actions/http_action.clj
@@ -113,32 +113,40 @@
 (defn execute-http-action!
   "Calls an http endpoint based on action and params"
   [action params->value]
-  (let [{:keys [method url body headers]} (:template action)
-        request {:method (keyword method)
-                 :url (parse-and-substitute url params->value)
-                 :accept :json
-                 :content-type :json
-                 :throw-exceptions false
-                 :headers (merge
-                            ;; TODO maybe we want to default Agent here? Maybe Origin/Referer?
-                            {"X-Metabase-Action" (:name action)}
-                            (-> headers
-                                (parse-and-substitute params->value)
-                                (json/decode)))
-                 :body (parse-and-substitute body params->value)}
-        response (update (select-keys (http/request request) [:body :headers :status]) :body json/decode)
-        ;; TODO this is pretty ineficient. We parse with `:as :json`, then reencode within a response
-        ;; I couldn't find a way to get JSONNode out of cheshire, so we fall back to jackson.
-        ;; Should jackson be added explicitly to deps.edn?
-        response-node (.readTree ^ObjectMapper @object-mapper (json/generate-string response))
-        error (json/parse-string (apply-json-query response-node (or (:error_handle action) ".status >= 400")))]
-    (if error
-      {:status 400
-       :headers {"Content-Type" "application/json"}
-       :body (when-not (boolean? error) error)}
-      (if-some [response (some->> action :response_handle (apply-json-query response-node))]
-        {:status 200
+  (try
+    (let [{:keys [method url body headers]} (:template action)
+          request {:method (keyword method)
+                   :url (parse-and-substitute url params->value)
+                   :accept :json
+                   :content-type :json
+                   :throw-exceptions false
+                   :headers (merge
+                              ;; TODO maybe we want to default Agent here? Maybe Origin/Referer?
+                              {"X-Metabase-Action" (:name action)}
+                              (-> headers
+                                  (parse-and-substitute params->value)
+                                  (json/decode)))
+                   :body (parse-and-substitute body params->value)}
+          response (update (select-keys (http/request request) [:body :headers :status]) :body json/decode)
+          ;; TODO this is pretty ineficient. We parse with `:as :json`, then reencode within a response
+          ;; I couldn't find a way to get JSONNode out of cheshire, so we fall back to jackson.
+          ;; Should jackson be added explicitly to deps.edn?
+          response-node (.readTree ^ObjectMapper @object-mapper (json/generate-string response))
+          error (json/parse-string (apply-json-query response-node (or (:error_handle action) ".status >= 400")))]
+      (log/trace "Response before handle:" response)
+      (if error
+        {:status 400
          :headers {"Content-Type" "application/json"}
-         :body response}
-        {:status 204
-         :body nil}))))
+         :body (if (boolean? error)
+                 {:remote-status (:status response)}
+                 error)}
+        (if-some [response (some->> action :response_handle (apply-json-query response-node))]
+          {:status 200
+           :headers {"Content-Type" "application/json"}
+           :body response}
+          {:status 204
+           :body nil})))
+    (catch Exception e
+      (throw (ex-info (str "Problem building request: " (ex-message e))
+                      {:template (:template action)}
+                      e)))))
diff --git a/src/metabase/api/action.clj b/src/metabase/api/action.clj
index f9b55724194..c183eb72607 100644
--- a/src/metabase/api/action.clj
+++ b/src/metabase/api/action.clj
@@ -59,15 +59,13 @@
   (db/delete! HTTPAction :action_id action-id)
   api/generic-204-no-content)
 
-;; TODO -- 99% sure these are busted. See https://github.com/metabase/metabase/issues/23935
-
 (api/defendpoint POST "/"
   "Create a new HTTP action."
   [:as {action :body}]
   (when (not= "http" (:type action))
     (throw (ex-info (trs "Action type is not supported") {:status-code 400 :action action})))
-  (let [http-action (db/insert! HTTPAction action)]
-    (if-let [action-id (:action_id http-action)]
+  (let [action-id (action/insert! action)]
+    (if action-id
       (first (action/select-actions :id action-id))
       ;; db/insert! does not return a value when used with h2
       ;; so we return the most recently updated http action.
diff --git a/src/metabase/api/emitter.clj b/src/metabase/api/emitter.clj
index e1e7b6f99df..1bbc5e752ed 100644
--- a/src/metabase/api/emitter.clj
+++ b/src/metabase/api/emitter.clj
@@ -60,12 +60,14 @@
 (api/defendpoint PUT "/:emitter-id"
   "Endpoint to update an emitter."
   [emitter-id :as {emitter :body}]
+  (api/check-404 (Emitter emitter-id))
   (db/update! Emitter emitter-id emitter)
   api/generic-204-no-content)
 
 (api/defendpoint DELETE "/:emitter-id"
   "Endpoint to delete an emitter."
   [emitter-id]
+  (api/check-404 (Emitter emitter-id))
   (db/delete! Emitter :id emitter-id)
   api/generic-204-no-content)
 
@@ -75,52 +77,6 @@
                   s/Keyword s/Any}}
       (su/with-api-error-message "map of parameter name or ID -> map of parameter `:value` and `:type` of the value")))
 
-(comment
-  ;; Assuming things look like this
-;; GET native query action
-:parameters
-;;[
-;;    {
-;;        "id": "2fddf797-838e-81eb-4828-53f947932486",
-;;        "type": "number/=",
-;;        "target": [
-;;            "variable",
-;;            [
-;;                "template-tag",
-;;                "product_id"
-;;            ]
-;;        ],
-;;        "name": "Product",
-;;        "slug": "product_id"
-;;    }
-;;]
-
-;; PUT dashboards/:id
-:emitters
-:parameter_mappings
-;;{
-;;    "2fddf797-838e-81eb-4828-53f947932486": [
-;;        "variable",
-;;        [
-;;            "template-tag",
-;;            "product_id"
-;;        ]
-;;    ]
-;;}
-
-
-;; on execution
-:parameters
-;;{
-;;    "2fddf797-838e-81eb-4828-53f947932486": {
-;;        "value": 1,
-;;        "type": "number/="
-;;    }
-;;}
-
-
-  )
-
 (defn- execute-http-emitter!
   [emitter parameters]
   ;; TODO check the types match
diff --git a/src/metabase/api/testing.clj b/src/metabase/api/testing.clj
index 77fde0f26c3..36b6a2bbb4a 100644
--- a/src/metabase/api/testing.clj
+++ b/src/metabase/api/testing.clj
@@ -42,4 +42,12 @@
   (restore-snapshot! name)
   nil)
 
+(api/defendpoint POST "/echo"
+  [fail :as {:keys [body]}]
+  (if fail
+    {:status 400
+     :body {:error-code "oops"}}
+    {:status 200
+     :body body}))
+
 (api/define-routes)
diff --git a/src/metabase/models/action.clj b/src/metabase/models/action.clj
index 0bbf3a1f8d7..5546f0af655 100644
--- a/src/metabase/models/action.clj
+++ b/src/metabase/models/action.clj
@@ -1,5 +1,6 @@
 (ns metabase.models.action
-  (:require [medley.core :as m]
+  (:require [cheshire.core :as json]
+            [medley.core :as m]
             [metabase.models.interface :as mi]
             [metabase.util :as u]
             [metabase.util.encryption :as encryption]
@@ -10,19 +11,20 @@
 (models/defmodel HTTPAction :http_action)
 (models/defmodel Action :action)
 
+(models/add-type! ::json-with-nested-parameters
+  :in  (comp mi/json-in
+             (fn [template]
+               (u/update-if-exists template :parameters mi/normalize-parameters-list)))
+  :out (comp (fn [template]
+               (u/update-if-exists template :parameters (mi/catch-normalization-exceptions mi/normalize-parameters-list)))
+             mi/json-out-with-keywordization))
+
 (u/strict-extend (class Action)
   models/IModel
   (merge models/IModelDefaults
          {:types      (constantly {:type :keyword})
           :properties (constantly {:timestamped? true})}))
 
-(defn- pre-insert
-  [action]
-  (let [base-action (db/insert! Action (select-keys action [:type]))]
-    (-> action
-        (dissoc :type)
-        (assoc :action_id (u/the-id base-action)))))
-
 (defn- pre-update
   [action]
   ;; All possible sub-type columns
@@ -39,8 +41,7 @@
   (merge models/IModelDefaults
          {:primary-key (constantly :action_id) ; This is ok as long as we're 1:1
           :pre-delete pre-delete
-          :pre-update pre-update
-          :pre-insert pre-insert}))
+          :pre-update pre-update}))
 
 (u/strict-extend (class QueryAction)
   models/IModel
@@ -49,7 +50,22 @@
 (u/strict-extend (class HTTPAction)
   models/IModel
   (merge Action-subtype-IModel-impl
-         {:types (constantly {:template :json})}))
+         {:types (constantly {:template ::json-with-nested-parameters})}))
+
+(defn insert!
+  "Inserts an Action and related HTTPAction or QueryAction. Returns the action id."
+  [action-data]
+  (db/transaction
+    (let [action (db/insert! Action {:type (:type action-data)})
+          model (case (keyword (:type action))
+                  :http HTTPAction
+                  :query QueryAction)]
+      (db/execute! {:insert-into model
+                    :values [(-> action-data
+                                 (dissoc :type)
+                                 (u/update-if-exists :template json/encode)
+                                 (assoc :action_id (:id action)))]})
+      (:id action))))
 
 (def ^:private encrypted-json-out (comp mi/json-out-with-keywordization encryption/maybe-decrypt))
 
diff --git a/src/metabase/models/card.clj b/src/metabase/models/card.clj
index 93bd11910a4..574361065bd 100644
--- a/src/metabase/models/card.clj
+++ b/src/metabase/models/card.clj
@@ -217,8 +217,7 @@
 (defn- create-actions-when-is-writable! [{is-write? :is_write card-id :id}]
   (when is-write?
     (when-not (db/select-one action/QueryAction :card_id card-id)
-      (db/insert! action/QueryAction {:card_id card-id
-                                      :type :query}))))
+      (action/insert! {:card_id card-id :type :query}))))
 
 (defn- delete-actions-when-not-writable! [{is-write? :is_write card-id :id}]
   (when (not is-write?)
diff --git a/test/metabase/actions/test_util.clj b/test/metabase/actions/test_util.clj
index f4d1fbe4218..f29320190f8 100644
--- a/test/metabase/actions/test_util.clj
+++ b/test/metabase/actions/test_util.clj
@@ -3,11 +3,14 @@
    [clojure.java.jdbc :as jdbc]
    [clojure.test :refer :all]
    [metabase.driver.sql-jdbc.connection :as sql-jdbc.conn]
-   [metabase.models
-    :refer [Card CardEmitter Database Emitter EmitterAction QueryAction]]
+   [metabase.http-client :as client]
+   [metabase.models :refer [Action Card CardEmitter Dashboard DashboardEmitter
+                            Database Emitter EmitterAction QueryAction]]
+   [metabase.models.action :as action]
    [metabase.test :as mt]
    [metabase.test.data.dataset-definitions :as defs]
    [metabase.test.data.interface :as tx]
+   [metabase.test.initialize :as initialize]
    [toucan.db :as db]))
 
 (def ^:dynamic ^:private *actions-test-data-tables*
@@ -120,6 +123,8 @@
                                                                                         :display-name "ID"
                                                                                         :type         :number
                                                                                         :required     true}}}}
+                                       :name          "Query Example"
+                                       :parameters    [{:id "id" :type "number"}]
                                        :is_write      true}]]
     (let [action-id (db/select-one-field :action_id QueryAction :card_id card-id)]
       (f {:query-action-card-id card-id
@@ -135,37 +140,84 @@
   [[bindings] & body]
   `(do-with-query-action (fn [~bindings] ~@body)))
 
-(defn do-with-card-emitter
-  "Impl for [[with-card-emitter]]."
-  [{:keys [action-id], :as context} f]
-  (mt/with-temp* [Card    [{emitter-card-id :id}]
-                  Emitter [{emitter-id :id} {:parameter_mappings {"my_id" [:variable [:template-tag "id"]]}}]]
-    (testing "Sanity check: emitter-id should be non-nil"
-      (is (integer? emitter-id)))
-    (testing "Sanity check: make sure parameter mappings were defined the way we'd expect"
-      (is (= {:my_id [:variable [:template-tag "id"]]}
-             (db/select-one-field :parameter_mappings Emitter :id emitter-id))))
-    ;; these are tied to the Card and Emitter above and will get cascade deleted. We can't use `with-temp*` for them
-    ;; because it doesn't seem to work with tables with compound PKs
-    (db/insert! EmitterAction {:emitter_id emitter-id
-                               :action_id action-id})
-    (db/insert! CardEmitter {:card_id   emitter-card-id
-                             :action_id action-id})
-    (f (assoc context
-              :emitter-id      emitter-id
-              :emitter-card-id emitter-card-id))))
+(defn do-with-http-action
+  "Impl for [[with-http-action]]."
+  [f]
+  (initialize/initialize-if-needed! :web-server)
+  (mt/with-model-cleanup [Action]
+    (let [action-id (action/insert! {:type :http
+                                     :name "Echo Example"
+                                     :template {:url (client/build-url "testing/echo[[?fail={{fail}}]]" {})
+                                                :method :post
+                                                :body "{\"the_parameter\": {{id}}}"
+                                                :headers "{\"x-test\": \"{{id}}\"}"
+                                                :parameters [{:id "id" :type "number"}
+                                                             {:id "fail" :type "text"}]}
+                                     :response_handle ".body"})]
+      (f {:action-id action-id}))))
+
+(defmacro with-http-action
+  "Execute `body` with a newly created QueryAction. `bindings` is a map with key `:action-id`.
+
+    (with-http-action [{:keys [action-id], :as context}]
+      (do-something))"
+  {:style/indent 1}
+  [[bindings] & body]
+  `(do-with-http-action (fn [~bindings] ~@body)))
+
+(defn do-with-emitter
+  "Impl for [[with-emitter]]."
+  [card-or-dashboard-model {:keys [action-id], :as context} f]
+  (let [parent-model (db/resolve-model card-or-dashboard-model)]
+    (mt/with-temp* [parent-model [{emitter-parent-id :id}]
+                    Emitter [{emitter-id :id} {:parameter_mappings {"my_id" [:variable [:template-tag "id"]]
+                                                                    "my_fail" [:variable [:template-tag "fail"]]}}]]
+      (testing "Sanity check: emitter-id should be non-nil"
+        (is (integer? emitter-id)))
+      (testing "Sanity check: make sure parameter mappings were defined the way we'd expect"
+        (is (= {:my_id [:variable [:template-tag "id"]]
+                :my_fail [:variable [:template-tag "fail"]]}
+               (db/select-one-field :parameter_mappings Emitter :id emitter-id))))
+      ;; these are tied to the Card or Dashboad and Emitter above and will get cascade deleted. We can't use `with-temp*` for them
+      ;; because it doesn't seem to work with tables with compound PKs
+      (db/insert! EmitterAction {:emitter_id emitter-id
+                                 :action_id action-id})
+      (condp = (type parent-model)
+        (type Card)
+        (db/insert! CardEmitter {:card_id    emitter-parent-id
+                                 :emitter_id emitter-id})
+        (type Dashboard)
+        (db/insert! DashboardEmitter {:dashboard_id emitter-parent-id
+                                      :emitter_id   emitter-id}))
+      (f (assoc context
+                :emitter-id        emitter-id
+                :emitter-parent-id emitter-parent-id)))))
+
+(defmacro with-dashboard-emitter
+  "Execute `body` with a newly created DashboardEmitter created for an Action with `:action-id`. Intended for use with the
+  `context` returned by with [[with-query-action]]. `bindings` is bound to a map with the keys `:emitter-id` and
+  `:emitter-parent-id` pointing to the dashboard-id.
+
+    (with-query-action [{:keys [action-id query-action-card-id], :as context}]
+      (with-dashboard-emitter [{:keys [emitter-id emitter-parent-id]} context]
+        (do-something)))"
+  {:style/indent 1
+   :arglists '([bindings {:keys [action-id], :as _action}] & body)}
+  [[bindings action] & body]
+  `(do-with-emitter Dashboard ~action (fn [~bindings] ~@body)))
 
 (defmacro with-card-emitter
   "Execute `body` with a newly created CardEmitter created for an Action with `:action-id`. Intended for use with the
   `context` returned by with [[with-query-action]]. `bindings` is bound to a map with the keys `:emitter-id` and
-  `:emitter-card-id`.
+  `:emitter-parent-id` pointing to the card-id.
 
     (with-query-action [{:keys [action-id query-action-card-id], :as context}]
-      (with-card-emitter [{:keys [emitter-id emitter-card-id]} context]
+      (with-card-emitter [{:keys [emitter-id emitter-parent-id]} context]
         (do-something)))"
-  {:style/indent 1, :arglists '([bindings {:keys [action-id], :as _action}] & body)}
+  {:style/indent 1
+   :arglists '([bindings {:keys [action-id], :as _action}] & body)}
   [[bindings action] & body]
-  `(do-with-card-emitter ~action (fn [~bindings] ~@body)))
+  `(do-with-emitter Card ~action (fn [~bindings] ~@body)))
 
 (defn do-with-actions-enabled
   "Impl for [[with-actions-enabled]]."
diff --git a/test/metabase/api/emitter_test.clj b/test/metabase/api/emitter_test.clj
index 12c15032770..fb37bf8601f 100644
--- a/test/metabase/api/emitter_test.clj
+++ b/test/metabase/api/emitter_test.clj
@@ -1,8 +1,9 @@
 (ns metabase.api.emitter-test
   (:require
+   [clojure.string :as str]
    [clojure.test :refer :all]
    [metabase.actions.test-util :as actions.test-util]
-   [metabase.models :refer [Card Dashboard]]
+   [metabase.models :refer [Card Dashboard Emitter]]
    [metabase.test :as mt]
    [metabase.util.schema :as su]
    [schema.core :as s]))
@@ -31,16 +32,87 @@
                              (mt/user-http-request :crowberto :post 200 "emitter" {:dashboard_id dashboard-id
                                                                                    :action_id    action-id})))))))))))
 
-(deftest execute-custom-action-test
+(deftest update-emitter-test
+  (testing "PUT /api/emitter/:id"
+    (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+      (actions.test-util/with-actions-test-data-and-actions-enabled
+        (actions.test-util/with-query-action [action]
+          (actions.test-util/with-card-emitter [{:keys [emitter-id]} action]
+            (testing "Should be able to update an emitter"
+              (mt/user-http-request :crowberto :put 204 (format "emitter/%d" emitter-id)
+                                    {:options {:a 1}})
+              (is (partial= {:options {:a 1}} (Emitter emitter-id))))
+            (testing "Should 404 if bad emitter-id"
+              (is (= "Not found."
+                     (mt/user-http-request :crowberto :put 404 (format "emitter/%d" Integer/MAX_VALUE)
+                                           {}))))))))))
+
+(deftest delete-emitter-test
+  (testing "DELETE /api/emitter/:id"
+    (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+      (actions.test-util/with-actions-test-data-and-actions-enabled
+        (actions.test-util/with-query-action [action]
+          (actions.test-util/with-card-emitter [{:keys [emitter-id]} action]
+            (testing "Should be able to delete an emitter"
+              (is (nil? (mt/user-http-request :crowberto :delete 204 (format "emitter/%d" emitter-id)))))
+            (testing "Should 404 if bad emitter-id"
+              (is (= "Not found."
+                     (mt/user-http-request :crowberto :delete 404 (format "emitter/%d" Integer/MAX_VALUE)
+                                           {}))))))))))
+
+(deftest execute-query-action-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (actions.test-util/with-query-action [action]
+        (actions.test-util/with-card-emitter [{:keys [emitter-id]} action]
+          (let [emitter-path  (format "emitter/%d/execute" emitter-id)]
+            (testing "Should be able to execute an emitter"
+              (is (= {:rows-affected 1}
+                     (mt/user-http-request :crowberto :post 200 emitter-path
+                                           {:parameters {"my_id" {:type  :number/=
+                                                                  :value 1}}}))))
+            (is (= [1 "Bird Shop"]
+                   (mt/first-row
+                     (mt/run-mbql-query categories {:filter [:= $id 1]}))))
+            (testing "Should affect 0 rows if id is out of range"
+              (is (= {:rows-affected 0}
+                     (mt/user-http-request :crowberto :post 200 emitter-path
+                                           {:parameters {"my_id" {:type  :number/=
+                                                                  :value Integer/MAX_VALUE}}}))))
+            (testing "Should 404 if bad emitter-id"
+              (is (= "Not found."
+                     (mt/user-http-request :crowberto :post 404 (format "emitter/%d/execute" Integer/MAX_VALUE)
+                                           {}))))
+            (testing "Missing parameter should fail gracefully"
+              (is (partial= {:message "Error executing QueryEmitter: Error building query parameter map: Error determining value for parameter \"id\": You'll need to pick a value for 'ID' before this query can run."}
+                            (mt/user-http-request :crowberto :post 500 emitter-path
+                                                  {:parameters {}}))))
+            (testing "Sending an invalid number should fail gracefully"
+
+              (is (partial= {:message "Error executing QueryEmitter: Error building query parameter map: Error determining value for parameter \"id\": Unparseable number: \"BAD\""}
+                            (mt/user-http-request :crowberto :post 500 emitter-path
+                                                  {:parameters {"my_id" {:type :number/= :value "BAD"}}}))))))))))
+
+(deftest execute-http-action-test
   (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
     (actions.test-util/with-actions-test-data-and-actions-enabled
-      (actions.test-util/with-query-action [context]
+      (actions.test-util/with-http-action [context]
         (actions.test-util/with-card-emitter [{:keys [emitter-id]} context]
-          (testing "Should be able to execute an emitter"
-            (is (= {:rows-affected 1}
-                   (mt/user-http-request :crowberto :post 200 (format "emitter/%d/execute" emitter-id)
-                                         {:parameters {"my_id" {:type  :number/=
-                                                                :value 1}}}))))
-          (is (= [1 "Bird Shop"]
-                 (mt/first-row
-                  (mt/run-mbql-query categories {:filter [:= $id 1]})))))))))
+          (let [emitter-path (format "emitter/%d/execute" emitter-id)]
+            (testing "Should be able to execute an emitter"
+              (is (= {:the_parameter 1}
+                     (mt/user-http-request :crowberto :post 200 emitter-path
+                                           {:parameters {"my_id" {:type :number/= :value 1}}}))))
+            (testing "Should handle errors"
+              (is (= {:remote-status 400}
+                     (mt/user-http-request :crowberto :post 400 emitter-path
+                                           {:parameters {"my_id" {:type :number/= :value 1}
+                                                         "my_fail" {:type :text :value "true"}}}))))
+            (testing "Missing parameter should fail gracefully"
+              (is (partial= {:message "Problem building request: Cannot call the service: missing required parameters: #{\"id\"}"}
+                            (mt/user-http-request :crowberto :post 500 emitter-path
+                                                  {:parameters {}}))))
+            (testing "Sending an invalid number should fail gracefully"
+              (is (str/starts-with? (:message (mt/user-http-request :crowberto :post 500 emitter-path
+                                                  {:parameters {"my_id" {:type :number/= :value "BAD"}}}))
+                                    "Problem building request:")))))))))
diff --git a/test/metabase/models/action_test.clj b/test/metabase/models/action_test.clj
new file mode 100644
index 00000000000..23654a40e50
--- /dev/null
+++ b/test/metabase/models/action_test.clj
@@ -0,0 +1,34 @@
+(ns metabase.models.action-test
+  (:require [clojure.test :refer :all]
+            [metabase.actions.test-util :as actions.test-util]
+            [metabase.models :refer [Emitter]]
+            [metabase.test :as mt]
+            [toucan.hydrate :refer [hydrate]]))
+
+(deftest hydrate-query-action-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (actions.test-util/with-query-action [{:keys [query-action-card-id action-id] :as context}]
+        (actions.test-util/with-card-emitter [{:keys [emitter-id]} context]
+          (let [emitter (Emitter emitter-id)
+                hydrated-emitter (hydrate emitter :action)]
+            (is (partial=
+                  {:id action-id
+                   :name "Query Example"
+                   :card {:id query-action-card-id}
+                   :parameters [{:id "id" :type :number}]}
+                  (:action hydrated-emitter)))))))))
+
+(deftest hydrate-http-action-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (actions.test-util/with-http-action [{:keys [action-id] :as context}]
+        (actions.test-util/with-card-emitter [{:keys [emitter-id]} context]
+          (let [emitter (Emitter emitter-id)
+                hydrated-emitter (hydrate emitter :action)]
+            (is (partial=
+                  {:id action-id
+                   :name "Echo Example"
+                   :parameters [{:id "id" :type :number}
+                                {:id "fail" :type :text}]}
+                  (:action hydrated-emitter)))))))))
diff --git a/test/metabase/models/emitter_test.clj b/test/metabase/models/emitter_test.clj
new file mode 100644
index 00000000000..1c1ac327f14
--- /dev/null
+++ b/test/metabase/models/emitter_test.clj
@@ -0,0 +1,28 @@
+(ns metabase.models.emitter-test
+  (:require [clojure.test :refer :all]
+            [metabase.actions.test-util :as actions.test-util]
+            [metabase.models :refer [Card Dashboard]]
+            [metabase.test :as mt]
+            [toucan.hydrate :refer [hydrate]]))
+
+(deftest test-hydration
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (actions.test-util/with-query-action [context]
+        (actions.test-util/with-card-emitter [{:keys [emitter-id emitter-parent-id]} context]
+            (let [card (Card emitter-parent-id)
+                  hydrated-card (hydrate card :emitters)]
+              (is (partial=
+                    [{:id emitter-id}]
+                    (:emitters hydrated-card)))))))))
+
+(deftest dashboard-emitter-hydration-test
+  (mt/test-drivers (mt/normal-drivers-with-feature :actions/custom)
+    (actions.test-util/with-actions-test-data-and-actions-enabled
+      (actions.test-util/with-query-action [context]
+        (actions.test-util/with-dashboard-emitter [{:keys [emitter-id emitter-parent-id]} context]
+          (let [dashboard (Dashboard emitter-parent-id)
+                hydrated-card (hydrate dashboard [:emitters :action])]
+            (is (partial=
+                  [{:id emitter-id}]
+                  (:emitters hydrated-card)))))))))
-- 
GitLab