diff --git a/.clj-kondo/config.edn b/.clj-kondo/config.edn index 29d3a2360965..e0a672f69f57 100644 --- a/.clj-kondo/config.edn +++ b/.clj-kondo/config.edn @@ -177,6 +177,7 @@ clojure.core.async/take! clojure.core.async/to-chan! clojure.core.async/to-chan!! + metabase.api.macros.defendpoint.tools-manifest/assert-optional-fields-nullable! metabase.channel.core/send! metabase.warehouses.models.database/assert-not-h2! metabase.driver.sql-jdbc.execute/execute-prepared-statement! diff --git a/.clj-kondo/config/modules/config.edn b/.clj-kondo/config/modules/config.edn index 0847bd5ebdc4..dbf0ef4b1739 100644 --- a/.clj-kondo/config/modules/config.edn +++ b/.clj-kondo/config/modules/config.edn @@ -322,7 +322,8 @@ :api #{metabase.app-db.cluster-lock ; TODO FIXME metabase.app-db.core metabase.app-db.init - metabase.app-db.setup} + metabase.app-db.setup + metabase.app-db.transient-error} :uses #{auth-provider classloader config @@ -1679,7 +1680,6 @@ :model/NativeQuerySnippet :model/Notification :model/Revision - :model/Table :model/User}} queries-rest @@ -1928,6 +1928,7 @@ {:team "UX West" :api #{metabase.server.core metabase.server.init + metabase.server.middleware.security metabase.server.middleware.session metabase.server.settings metabase.server.streaming-response} ; TODO -- too many API namespaces @@ -3241,6 +3242,7 @@ :api #{metabase-enterprise.security-center.api metabase-enterprise.security-center.init} :uses #{analytics + analytics-interface api app-db channel diff --git a/.clj-kondo/src/hooks/common.clj b/.clj-kondo/src/hooks/common.clj index 622819a1b240..2b36c72e4ecc 100644 --- a/.clj-kondo/src/hooks/common.clj +++ b/.clj-kondo/src/hooks/common.clj @@ -500,8 +500,8 @@ (and resolved (:ns resolved)) (symbol (name (:ns resolved)) (name (:name resolved))) - ;; if it wasn't resolved but is still qualified it's probably using the full namespace name rather than an - ;; alias. + ;; if it wasn't resolved but is still qualified it's probably using the full namespace name rather than an + ;; alias. (qualified-symbol? sexpr) sexpr))))) ;; some symbols like `*count/Integer` aren't resolvable. diff --git a/.clj-kondo/src/hooks/metabase/test/util.clj b/.clj-kondo/src/hooks/metabase/test/util.clj index 7335f9b3ad6f..fbee34aac697 100644 --- a/.clj-kondo/src/hooks/metabase/test/util.clj +++ b/.clj-kondo/src/hooks/metabase/test/util.clj @@ -28,8 +28,8 @@ (mapcat (fn [[setting-name v]] (concat [(with-meta (hooks/token-node '_) (meta setting-name)) v] - ;; if the setting name is namespace-qualified add a `_` - ;; entry for it too. + ;; if the setting name is namespace-qualified add a `_` + ;; entry for it too. (when (namespaced-symbol-node? setting-name) [(with-meta (hooks/token-node '_) (meta setting-name)) setting-name])))) (partition 2 (:children bindings)))) diff --git a/.clj-kondo/src/hooks/metabase/util/i18n.clj b/.clj-kondo/src/hooks/metabase/util/i18n.clj index a8bd84cf90c0..802bbee8e74c 100644 --- a/.clj-kondo/src/hooks/metabase/util/i18n.clj +++ b/.clj-kondo/src/hooks/metabase/util/i18n.clj @@ -13,23 +13,23 @@ (let [c (first chars)] (if (= c \') (cond - ;; Doubled quotes: skip both + ;; Doubled quotes: skip both (and (next chars) (= (second chars) \')) (recur (nthrest chars 2)) - ;; Placeholder escape. We allow for intentional escaping, like either '{0}` or '{{`, but not 'arbitrary - ;; strings' So basically, just require the *next* character to be a `{`, find the next single-quote, and - ;; continue on from there. - ;; - ;; Technically a string like "foo bar's happy but i'm not" is totally fine, but we'll say it's invalid - ;; because... that inner string literal is probably not what we actually want. So since we've never done - ;; this intentionally as far as I can tell, let's just remove the possibility to avoid ambiguity. + ;; Placeholder escape. We allow for intentional escaping, like either '{0}` or '{{`, but not 'arbitrary + ;; strings' So basically, just require the *next* character to be a `{`, find the next single-quote, and + ;; continue on from there. + ;; + ;; Technically a string like "foo bar's happy but i'm not" is totally fine, but we'll say it's invalid + ;; because... that inner string literal is probably not what we actually want. So since we've never done + ;; this intentionally as far as I can tell, let's just remove the possibility to avoid ambiguity. (= (second chars) \{) (let [at-closing (drop-while #(not= \' %) (next chars))] (if (seq at-closing) (recur (next at-closing)) false)) - ;; Unpaired single quote: invalid + ;; Unpaired single quote: invalid :else false) (recur (rest chars))))))) diff --git a/.cljfmt.edn b/.cljfmt.edn index 7856b7a10c85..75204d52f1b3 100644 --- a/.cljfmt.edn +++ b/.cljfmt.edn @@ -1,12 +1,10 @@ ;; Cljfmt config. See https://github.com/weavejester/cljfmt#formatting-options -{:sort-ns-references? - true - - :function-arguments-indentation - :community - - :parallel? - true +{:function-arguments-indentation :community + :indent-line-comments? true + :normalize-newlines-at-file-end? true + :parallel? true + :remove-blank-lines-in-forms? true + :sort-ns-references? true :paths ["src" @@ -15,11 +13,63 @@ "bin/build" "bin/lint-migrations-file" "bin/release-list" + #_"mage/src" + #_"mage/test" "modules/drivers" ".clj-kondo/src" ".clj-kondo/test" #_"dev"] + ;; a map of symbols that tell cljfmt which forms are allowed to have blank lines inside of them. The value may be + ;; either `:all`, which means blank lines are allowed between all elements in the form, e.g. `{cond :all}` to allow + ;; blank lines between any of the elements inside cond; or it may be a set of element indexes that are allowed to + ;; have blank lines, e.g. `{let #{0}}`, to allow blank lines in the binding of a `let` form. + :extra-blank-line-forms + {are :all + case :all + cond-> :all + cond->> :all + condp :all + clojure.spec.alpha/or :all + clojure.test/are :all + metabase.driver-api.core/match-many :all + metabase.driver-api.core/match-one :all + metabase.driver-api.core/replace :all + metabase.driver-api.core/replace-in :all + metabase.lib.drill-thru.test-util/test-drill-variants-with-merged-args :all + metabase.lib.drill-thru.test-util/test-returns-drill :all + metabase.test/with-temp #{0} + metabase.util.match/match-many :all + metabase.util.match/match-one :all + metabase.util.match/replace :all + metabase.util.match/replace-in :all + ;; TODO (Cam 2026-05-21) I'm not really sure about `defprotocol`, `deftype`, `defrecord`, `proxy`, `reify`, etc., I + ;; think idiomatically these should probably not have blank lines but we can worry about that later + defprotocol :all + defrecord :all + deftype :all + extend :all + extend-protocol :all + potemkin.types/def-map-type :all + potemkin.types/defprotocol+ :all + potemkin.types/defrecord+ :all + potemkin.types/deftype+ :all + potemkin/def-map-type :all + potemkin/defprotocol+ :all + potemkin/defrecord+ :all + potemkin/deftype+ :all + proxy :all + reify :all + ;; TODO (Cam 2026-05-21) -- save these for a follow-on PR + defmacro :all + defmethod :all + defn :all + defn- :all + fn :all + metabase.util.malli/defmethod :all + metabase.util.malli/defn :all + metabase.util.malli/defn- :all} + ;; See https://github.com/weavejester/cljfmt/blob/master/cljfmt/resources/cljfmt/indents/clojure.clj for the default ;; definitions ;; @@ -37,7 +87,7 @@ ;; `:inner` indentation is like `:defn` indentation. `[[:inner 0]]` (default for `defn`) means all arguments at depth ;; zero get indented 2 spaces if not on the first line regardless of number of elements on the first line. :extra-indents - {;; clojure.core stuff + { ;; clojure.core stuff fn* [[:inner 0]] let* [[:block 1]] with-meta [[:default]] diff --git a/.github/workflows/drivers.yml b/.github/workflows/drivers.yml index bacbbb35dc7b..a0e89ee9f031 100644 --- a/.github/workflows/drivers.yml +++ b/.github/workflows/drivers.yml @@ -1625,7 +1625,9 @@ jobs: # if you add a driver job, you must add it to this list for it to be a required check - be-tests-h2 - be-tests-athena-ee - - be-tests-bigquery-cloud-sdk-ee + # these are not reliable, but we want to run them anyway to gather data about failures + # - be-tests-snowflake-ee + # - be-tests-bigquery-cloud-sdk-ee - be-tests-druid-ee - be-tests-databricks-ee - be-tests-druid-jdbc-ee @@ -1637,7 +1639,6 @@ jobs: - be-tests-postgres - be-tests-presto-jdbc-ee - be-tests-redshift-ee - - be-tests-snowflake-ee - be-tests-sparksql-ee - be-tests-sqlite-ee - be-tests-sqlserver @@ -1665,7 +1666,7 @@ jobs: })); // are all jobs skipped or successful? - if (jobs.every(job => (job.result === 'skipped' || job.result === 'success' || job.name == 'be-tests-starburst-ee'))) { + if (jobs.every(job => (job.result === 'skipped' || job.result === 'success'))) { console.log(""); console.log(" _------. "); console.log(" / , \_ "); diff --git a/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Dark_Theme_Parameter_Search_With_Value.png b/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Dark_Theme_Parameter_Search_With_Value.png index 39ba605317b8..a1bd59e27677 100644 Binary files a/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Dark_Theme_Parameter_Search_With_Value.png and b/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Dark_Theme_Parameter_Search_With_Value.png differ diff --git a/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Light_Theme_Parameter_Search_With_Value.png b/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Light_Theme_Parameter_Search_With_Value.png index e6b4cde1f173..7db66d547e51 100644 Binary files a/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Light_Theme_Parameter_Search_With_Value.png and b/.loki/reference/chrome_laptop_App_Embed_PublicOrEmbeddedDashboardView_filters_Light_Theme_Parameter_Search_With_Value.png differ diff --git a/.loki/reference/chrome_laptop_Design_System_Colors_Default.png b/.loki/reference/chrome_laptop_Design_System_Colors_Default.png index 21a104f276fd..092a5aab3879 100644 Binary files a/.loki/reference/chrome_laptop_Design_System_Colors_Default.png and b/.loki/reference/chrome_laptop_Design_System_Colors_Default.png differ diff --git a/bb.edn b/bb.edn index effd98b60e39..afae422c3a86 100644 --- a/bb.edn +++ b/bb.edn @@ -2,7 +2,10 @@ ;; we put path as bin, and everything is in the ./mage subdirectory, ;; so the namespaces are mage.cli, mage.format, etc. :paths ["mage/src" "mage/test" "bin/lint-migrations-file/src"] - :deps {dev.weavejester/cljfmt {:mvn/version "0.15.3"} ;; run cljfmt from bb + ;; use Cam's fork temporarily until a new release with https://github.com/weavejester/cljfmt/pull/420 is cut + ;; (release > 0.16.4) + :deps {dev.weavejester/cljfmt {:git/sha "05886be17744846685e6a733973fdfbd24e1acd5" + :git/url "https://github.com/camsaul/cljfmt"} ;; run cljfmt from bb io.github.paintparty/bling {:mvn/version "0.8.8"} ;; printing bells and whistles medley/medley {:mvn/version "1.3.0"} ;; utilities metosin/malli {:mvn/version "0.17.0"} ;; data validation diff --git a/bin/build/src/build.clj b/bin/build/src/build.clj index e71bae380361..7772a0896d22 100644 --- a/bin/build/src/build.clj +++ b/bin/build/src/build.clj @@ -64,7 +64,6 @@ (run! (comp (partial u/error "Missing License: %s") first) without-license)) (u/announce "License information generated at %s" output-filename))) - (u/step "Run `bun run generate-license-disclaimer`" (u/sh {:dir u/project-root-directory} "bun" "run" "generate-license-disclaimer")))) diff --git a/bin/build/src/build/uberjar.clj b/bin/build/src/build/uberjar.clj index 5f380631bb8b..4ae1b8dff8ab 100644 --- a/bin/build/src/build/uberjar.clj +++ b/bin/build/src/build/uberjar.clj @@ -1,12 +1,14 @@ (ns build.uberjar (:require [clojure.java.io :as io] + [clojure.string :as str] [clojure.tools.build.api :as b] [clojure.tools.build.util.zip :as build.zip] [clojure.tools.namespace.dependency :as ns.deps] [clojure.tools.namespace.find :as ns.find] [clojure.tools.namespace.parse :as ns.parse] [metabuild-common.core :as u] + [metabuild-common.misc :as misc] [org.corfield.log4j2-conflict-handler :refer [log4j2-conflict-handler]]) (:import (java.io File OutputStream) @@ -167,13 +169,45 @@ #"META-INF/license.*" #"META-INF/LICENSE.*"]) +(defn- prefer-lib + "Returns a conflict handler fn that ensures `preferred` lib's classes always win. + The returned fn writes when the incoming class is from `preferred`, skips otherwise." + [preferred] + (fn prefer-lib' [{:keys [lib path in]}] + (when (= lib preferred) + {:write {path {:stream in}}}))) + +;; hive-jdbc bundles javax.activation classes with package-private visibility on LogSupport. +;; When these overwrite jakarta.activation's public versions, javax.mail (postal) fails with +;; IllegalAccessError. +(def ^:private activation-conflict-handler + (let [from-com-sun-activation (prefer-lib 'com.sun.activation/jakarta.activation)] + {"com/sun/activation/.*" from-com-sun-activation + "javax/activation/.*" from-com-sun-activation})) + +;; vertica-jdbc bundles unshaded gson 2.8.9. When these overwrite the pinned 2.12.1, +;; BigQuery crashes with NoSuchMethodError on JsonWriter.value(float). See #73736. +(def ^:private gson-conflict-handler + {"com/google/gson/.*" (prefer-lib 'com.google.code.gson/gson)}) + +;; avatica (Hive transitive dep) bundles the entire SLF4J API unshaded. +(def ^:private slf4j-conflict-handler + {"org/slf4j/.*" (prefer-lib 'org.slf4j/slf4j-api)}) + +(def conflict-handlers + "Merged conflict handlers for the uberjar build. Handles Log4j2 plugin merging, + jakarta.activation class visibility, gson version pinning, and SLF4J API." + (merge log4j2-conflict-handler + activation-conflict-handler + gson-conflict-handler + slf4j-conflict-handler)) + (defn- create-uberjar! [basis] (u/step "Create uberjar" (with-duration-ms [duration-ms] (b/uber {:class-dir class-dir :uber-file uberjar-filename - ;; merge Log4j2Plugins.dat files. (#50721) - :conflict-handlers log4j2-conflict-handler + :conflict-handlers conflict-handlers :basis basis :exclude dependency-ignore-patterns}) (u/announce "Created uberjar in %.1f seconds." (/ duration-ms 1000.0))))) @@ -209,8 +243,8 @@ :when (.isFile f)] (let [rel-path (.toString (.relativize (.toPath src-dir) (.toPath f))) target (u/get-path-in-filesystem fs rel-path)] - (Files/createDirectories (.getParent target) (into-array java.nio.file.attribute.FileAttribute [])) - (Files/copy (.toPath f) target (into-array java.nio.file.CopyOption []))))))))) + (Files/createDirectories (.getParent target) (misc/varargs java.nio.file.attribute.FileAttribute)) + (Files/copy (.toPath f) target (misc/varargs java.nio.file.CopyOption))))))))) (defn update-manifest! "Start a build step that updates the manifest. @@ -241,3 +275,42 @@ (add-non-aot-driver-sources!) (update-manifest!)) (u/announce "Built %s in %.1f seconds." uberjar-filename (/ duration-ms 1000.0))))) + +(defn detect-class-conflicts + "Run `b/uber` against `basis` (no AOT, no resources) and return a seq of + `{:path ... :lib ...}` for every `.class` file conflict not already handled + by our conflict handlers." + [basis] + (let [conflicts (atom [])] + (clean!) + (b/uber {:class-dir class-dir + :uber-file uberjar-filename + :conflict-handlers (merge conflict-handlers + {:default (fn [{:keys [lib path]}] + (when (str/ends-with? path ".class") + (swap! conflicts conj {:path path :lib lib})) + nil)}) + :basis basis + :exclude dependency-ignore-patterns}) + @conflicts)) + +(defn audit-conflicts + "Build a bare uberjar (no AOT, no resources) and report all class file conflicts. + Useful for detecting vendored/unshaded dependencies in fat JARs. + + clojure -X:build:build/uberjar build.uberjar/audit-conflicts + clojure -X:build:build/uberjar build.uberjar/audit-conflicts :edition :ee" + [{:keys [edition], :or {edition :ee}}] + (u/step (format "Audit %s uberjar class file conflicts" edition) + (let [basis (create-basis edition) + conflicts (detect-class-conflicts basis)] + (when (seq conflicts) + (u/announce "=== %d class file conflicts detected ===" (count conflicts)) + (let [report-file "target/conflict-report.txt"] + (spit report-file + (str/join "\n" + (for [[path libs] (->> conflicts + (group-by :path) + (sort-by key))] + (format "%s — %s" path (str/join ", " (map :lib libs)))))) + (u/announce "Conflict report written to %s" report-file)))))) diff --git a/bin/build/src/metabuild_common/core.clj b/bin/build/src/metabuild_common/core.clj index 3ca64619543c..d00dd8c948e3 100644 --- a/bin/build/src/metabuild_common/core.clj +++ b/bin/build/src/metabuild_common/core.clj @@ -25,10 +25,8 @@ (p/import-vars [entrypoint exit-when-finished-nonzero-on-exception] - [build.env env-or-throw] - [files absolute? assert-file-exists @@ -46,28 +44,23 @@ temporary-file with-open-jar-file-system zip-directory->file] - [input interactive? letter-options-prompt read-line-with-prompt yes-or-no-prompt] - [misc parse-as-keyword since-ms start-timer varargs] - [out announce error pretty-print-exception safe-println] - [shell sh sh*] - [steps step]) diff --git a/bin/build/test/build/uberjar_test.clj b/bin/build/test/build/uberjar_test.clj new file mode 100644 index 000000000000..6cceba50cf61 --- /dev/null +++ b/bin/build/test/build/uberjar_test.clj @@ -0,0 +1,59 @@ +(ns build.uberjar-test + (:require + [build.uberjar :as uberjar] + [clojure.test :refer [deftest is testing]])) + +(set! *warn-on-reflection* true) + +(def ^:private basis + "EE basis — includes all drivers, which is where the worst conflicts live." + (delay (#'uberjar/create-basis :ee))) + +(defn- conflicting-prefixes + "Return Java package paths that have class file conflicts. + e.g. `com/google/gson/Gson.class` → `com/google/gson`" + [conflicts] + (into #{} + (keep (fn [{:keys [path]}] + (let [last-slash (.lastIndexOf ^String path "/")] + (when (pos? last-slash) + (subs path 0 last-slash))))) + conflicts)) + +;; Known conflicting packages. The test asserts on *package prefix* (stable) rather than +;; *lib* (order-dependent — the conflict handler only sees the second writer). +;; As we fix these, remove entries — the goal is to shrink this to #{}. +(def ^:private known-conflicting-prefixes + '#{"com/microsoft/schemas" ;; poi-ooxml vs poi-ooxml-lite — same version, XML schema overlap + "org/openxmlformats/schemas" ;; poi-ooxml vs poi-ooxml-lite — same version + "org/apache/poi/schemas" ;; poi-ooxml vs poi-ooxml-lite — same version + "org/w3/x2000" ;; poi-ooxml vs poi-ooxml-lite — XML digital signature schemas + "org/etsi/uri" ;; poi-ooxml vs poi-ooxml-lite — digital signature schemas + "jakarta/servlet" ;; jetty-servlet vs jakarta.servlet-api — same API version + "javax/annotation" ;; jsr250-api vs jsr305 — annotation-only JARs + "net/jcip/annotations" ;; jcip-annotations vs stephenc jcip-annotations — same lib, two Maven coords + "io/netty/buffer" ;; databricks-jdbc-thin bundles custom Arrow netty buffers + "org/apache/calcite/avatica" ;; avatica vs avatica-core — Hive transitive dep + ;; org/slf4j — handled by slf4j-conflict-handler (prefers org.slf4j/slf4j-api) + "org/apache/hadoop" ;; hadoop-common single-class overlap + "org/apache/hive"}) ;; hive-common single-class overlap + +(defn- prefix-matches? + "True if `prefix` equals or is under any of the known prefixes." + [prefix] + (some (fn [known] + (or (= prefix known) + (.startsWith ^String prefix (str known "/")))) + known-conflicting-prefixes)) + +(deftest class-file-conflicts-test + (testing "No unexpected class file conflicts in the EE uberjar" + ;; Takes ~2 minutes — runs b/uber without AOT or resources + (let [conflicts (uberjar/detect-class-conflicts @basis) + prefixes (conflicting-prefixes conflicts) + unexpected (sort (remove prefix-matches? prefixes))] + (is (empty? unexpected) + (str "Unexpected class file conflicts in packages:\n" + (pr-str unexpected) + "\nIf benign, add to known-conflicting-prefixes with a comment. " + "If dangerous, add a conflict handler in build.uberjar."))))) diff --git a/bin/build/test/i18n/autofix_test.clj b/bin/build/test/i18n/autofix_test.clj index f1bc5309483a..beb133c26bb4 100644 --- a/bin/build/test/i18n/autofix_test.clj +++ b/bin/build/test/i18n/autofix_test.clj @@ -48,20 +48,16 @@ "It's {0}" "It''s {0}" "won't {0} do" "won''t {0} do" "Cam's file" "Cam''s file")) - (testing "Already-escaped apostrophes are not re-doubled" (is (= "It''s fine" (fix-backend-str "It''s fine")))) - (testing "Intentional MessageFormat escapes (apostrophe adjacent to {}) are preserved" (are [input expected] (= expected (fix-backend-str input)) "'{0}'" "'{0}'" "'{login}' literal" "'{login}' literal" "'''{{...}}''' clause" "'''{{...}}''' clause")) - (testing "Strings without apostrophes pass through unchanged" (is (= "Hello {0}" (fix-backend-str "Hello {0}"))) (is (= "" (fix-backend-str "")))) - (testing "nil msgstr passes through as nil" (is (nil? (fix-backend-str nil))))) @@ -88,7 +84,6 @@ :messages []}] (is (= (:headers input) (:headers (autofix/autofix-po-contents input)))))) - (testing "Message count is preserved — autofix transforms, never drops" (let [input {:headers {} :messages [(backend-msg "a'b") diff --git a/bin/lint-migrations-file/src/lint_migrations_file.cljc b/bin/lint-migrations-file/src/lint_migrations_file.cljc index 8f6fb75837f7..5fc3a41c6630 100644 --- a/bin/lint-migrations-file/src/lint_migrations_file.cljc +++ b/bin/lint-migrations-file/src/lint_migrations_file.cljc @@ -116,14 +116,14 @@ (let [match-target-types? (fn [ttype] (contains? types (str/lower-case ttype)))] (cond - ;; a createTable or addColumn change; see if it adds a target-type col + ;; a createTable or addColumn change; see if it adds a target-type col (or (:createTable change) (:addColumn change)) (let [op (cond (:createTable change) :createTable (:addColumn change) :addColumn)] (some (fn [col-def] (match-target-types? (get-in col-def [:column :type] ""))) (get-in change [op :columns]))) - ;; a modifyDataType change; see if it change a column to target-type + ;; a modifyDataType change; see if it change a column to target-type (and (:modifyDataType change) (match-target-types? (get-in change [:modifyDataType :newDataType] ""))) true))) diff --git a/bin/lint-migrations-file/test/lint_migrations_file_test.clj b/bin/lint-migrations-file/test/lint_migrations_file_test.clj index 0c5c27cb0db0..dc10ba1a3edf 100644 --- a/bin/lint-migrations-file/test/lint_migrations_file_test.clj +++ b/bin/lint-migrations-file/test/lint_migrations_file_test.clj @@ -89,7 +89,6 @@ (validate (mock-change-set :id "v45.00-002") (mock-change-set :id "v45.00-001"))) - (is-thrown-with-error-info? "Change set IDs are not in order" {:out-of-order-ids [["v49.2023-12-14T08:54:54" @@ -168,7 +167,6 @@ [{:sql {:dbms "h2", :sql "1"}} {:sql {:dbms "postgresql", :sql "2"}} {:sql {:dbms "mysql,mariadb", :sql "3"}}]))))) - (testing "should fail if *any* change is missing dbms" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -178,7 +176,6 @@ :changes [{:sql {:dbms "h2", :sql "1"}} {:sql {:sql "2"}}]))))) - (testing "should fail if a DBMS is repeated" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -198,7 +195,6 @@ (testing "Valid new-style ID" (is (= :ok (validate-id "v49.2024-01-01T10:30:00")))) - (testing "invalid date components should throw an error" (let [validate-id-strict (fn [id] (validate-file (io/file "049_update_migrations.yaml") @@ -357,7 +353,6 @@ :remarks "none" :type "text"}}]}}] :rollback nil)))) - (testing "should throw if changes contains boolean type" (is-thrown-with-error-info? "Migration(s) ['v49.00-033'] uses invalid types (in 'boolean')" @@ -366,7 +361,6 @@ (validate (mock-change-set :id "v49.00-033" :changes [{:modifyDataType {:newDataType "boolean"}}] :rollback nil))) - (is-thrown-with-error-info? "Migration(s) ['v49.00-033'] uses invalid types (in 'boolean')" {:invalid-ids ["v49.00-033"] @@ -384,7 +378,6 @@ :columns [{:column {:name "foo" :remarks "none" :type "boolean"}}]}}]))))) - (testing "should throw if changes contains datetime type" (is-thrown-with-error-info? "Migration(s) ['v49.00-033'] uses invalid types (in 'timestamp','timestamp without time zone','datetime')" @@ -393,7 +386,6 @@ (validate (mock-change-set :id "v49.00-033" :changes [{:modifyDataType {:newDataType "datetime"}}] :rollback nil))) - (testing "(but not if it's an older migration)" (is (validate (mock-change-set :id "v45.12-345" :changes [{:createTable {:tableName "my_table" diff --git a/bin/release-list/src/release_list/main.clj b/bin/release-list/src/release_list/main.clj index 1346e6df33fa..d1bebdb42762 100644 --- a/bin/release-list/src/release_list/main.clj +++ b/bin/release-list/src/release_list/main.clj @@ -98,8 +98,7 @@ ;; Clear existing list of releases (let [target "../../docs/releases.md"] (shell (str "rm -rf " target)) - - ;; Publish releases + ;; Publish releases (spit target (-> list-of-releases prep-links diff --git a/bun.lock b/bun.lock index 48baa1dd6cba..fbde66ec7c21 100644 --- a/bun.lock +++ b/bun.lock @@ -321,7 +321,7 @@ "husky": "^9.1.7", "inquirer-file-selector": "^0.2.1", "jest": "^30.0.0", - "jest-canvas-mock": "^2.5.2", + "jest-canvas-mock": "2.5.2", "jest-environment-jsdom": "^30.0.0", "jest-junit": "^16.0.0", "jest-watch-typeahead": "^3.0.0", diff --git a/docs/ai/agent-api.md b/docs/ai/agent-api.md index e82df6d1fc1a..a516b7307b45 100644 --- a/docs/ai/agent-api.md +++ b/docs/ai/agent-api.md @@ -1,14 +1,17 @@ --- title: Agent API summary: The Agent API is a REST API for building headless, agentic BI applications on top of Metabase's semantic layer, scoped to an authenticated user's permissions. - --- # Agent API -The [Agent API](../api.html#tag/apiagent) is a REST API for building headless, agentic BI applications on top of Metabase's semantic layer, scoped to an authenticated user's permissions. +The [Agent API](../api.html#tag/apiagent) is a REST API for building headless, agentic BI applications on top of Metabase's semantic layer, scoped to an authenticated user's permissions. Agent API powers Metabase's MCP server. + +## Enable Agent API + +_Admin > AI > MCP_ -Admins enable the Agent API under **Admin > AI > MCP**. See [Agent API settings](./settings.md#agent-api-settings). +Admins enable the Agent API under **Admin > AI > MCP**. ## Agent API endpoints and reference diff --git a/docs/ai/mcp.md b/docs/ai/mcp.md index 60eea1fc7cd8..ce852de02831 100644 --- a/docs/ai/mcp.md +++ b/docs/ai/mcp.md @@ -9,9 +9,39 @@ summary: Connect MCP-compatible AI clients to Metabase to search, explore, and q Metabase includes an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server (using Streamable HTTP transport) that lets AI clients connect directly to your Metabase, all scoped to the connecting person's permissions. +# Enable MCP server + +_Admin > AI > MCP_ + +MCP server and Agent API settings live on their own subpage. From **Admin > AI**, open the **MCP** tab in the left sidebar. + +Use the **MCP server** toggle to turn external access to the [MCP server](./mcp.md) on or off. + +### Supported MCP clients + +Under **Supported MCP clients**, switch on any clients you want to allow: + +- **Claude** (Claude Desktop and Claude on the web) +- **Cursor and VS Code** +- **ChatGPT** + +Toggling on a client automatically adds that client's sandbox domains to Metabase's CORS allowlist, which is what lets browser-based MCP clients make cross-origin requests to your Metabase. + +Some clients run outside the browser (like Claude Code on your own machine) and don't need a CORS allowlist entry. You can connect those clients without toggling anything on (assuming you've turned on the main MCP server setting). + +### Custom MCP client domains + +If you run a self-hosted MCP client, or a client that isn't in the supported list, add the client's domain to the **Custom MCP client domains** field. Separate values with a space, for example: + +``` +https://mcp.internal.example.com https://*.staging.example.com +``` + +The field accepts wildcards (`*`) for subdomains. Changes take effect in about a minute. Might be a good time to get up and pour yourself a glass of water. + ## Connect an MCP client -If your admin has turned on [your Metabase's MCP server](./settings.md#enable-mcp-server), all you need to do is point your MCP client at Metabase's MCP endpoint, `/api/mcp`. For example: +If your admin has turned on [your Metabase's MCP server](#enable-mcp-server), all you need to do is point your MCP client at Metabase's MCP endpoint, `/api/mcp`. For example: ``` https://{your-metabase.example.com}/api/mcp @@ -60,7 +90,7 @@ MCP server requests are handled by whatever AI client you're using (like a deskt For example, if you ask your AI client to use your Metabase's MCP server "what's our q3 revenue," your client will interact with the MCP server to figure out which tools it needs to field your request. Your AI can decide that it needs to use the tool **construct_query** and **execute_query**, and what those queries might be. Then your client will call those tools for Metabase to run. -You don't need to have an [AI provider](settings.md#supported-providers) configured in Metabase to use your Metabase's MCP server. If you _do_ have an AI provider configured in Metabase to power Metabot, that provider will _not_ be used for MCP server requests. MCP calls by your local client have no effect on token usage for your Metabase's AI connection. +You don't need to have an [AI provider](settings.md#choose-ai-provider) configured in Metabase to use your Metabase's MCP server. If you _do_ have an AI provider configured in Metabase to power Metabot, that provider will _not_ be used for MCP server requests. MCP calls by your local client have no effect on token usage for your Metabase's AI connection. ## Available tools diff --git a/docs/ai/overview.md b/docs/ai/overview.md index 3e61f0c33c4c..07f21dfd1cd7 100644 --- a/docs/ai/overview.md +++ b/docs/ai/overview.md @@ -1,9 +1,9 @@ --- -title: AI in Metabase +title: AI in Metabase overview summary: Overview of all the ways you can use AI with Metabase. --- -# AI in Metabase +# AI in Metabase overview AI in Metabase is optional. You can use Metabase without AI at all. But if you do want to use AI to interact with Metabase, we have you covered. diff --git a/docs/ai/settings.md b/docs/ai/settings.md index 9a503b7e3d13..8927aa390950 100644 --- a/docs/ai/settings.md +++ b/docs/ai/settings.md @@ -7,8 +7,6 @@ redirect_from: # AI settings -> AI features are available on [Metabase Cloud](https://www.metabase.com/features/metabot-ai) and on self-hosted Metabase, using either the Metabase AI service or your own AI provider API key. - _Admin > AI_ This page covers admin settings for AI features in Metabase, including [Metabot](./metabot.md). To limit _who_ can use Metabot, see [AI controls](./usage-controls.md). @@ -19,16 +17,53 @@ AI features are available on both Metabase Cloud and self-hosted Metabase. To tu 1. Go to **Admin settings > AI**. 2. In **Connect to an AI provider**, choose a **Provider**: - - **Metabase**: The Metabase AI service. Metabase picks a benchmarked, cost-effective model for you, and billing is managed through your Metabase account. Agree to the **Metabase AI add-on Terms of Service** and click **Connect**. + - **Metabase**: The Metabase AI service. Metabase picks a benchmarked, cost-effective model for you, and charges you on token usage. See [Choose AI provider](#choose-ai-provider) - Another supported provider. See [bring your own API key](#bring-your-own-api-key). 3. Once connected, configure [Metabot](#configure-metabot) and other AI features below. > The Metabase AI add-on only appears in your Metabase Store account after you've connected to the Metabase AI service in **Admin settings > AI**. If you're on a Pro trial and don't see the add-on in **Manage plan**, connect it from Admin first; the Store will reflect it after. -## Bring your own API key +## Choose AI provider _Admin > AI_ +You can choose which AI provider and model is used to power Metabase's built-in agent. + +- If you're **self-hosting Metabase** and want to use Metabot, you need to [bring your own AI API key](#bring-your-own-api-key). +- On **Metabase Cloud**, you can either [bring your own AI API key](#bring-your-own-api-key) or [use the Metabase AI Service](#metabase-ai-service). + +The AI provider that you specify in AI settings powers Metabase's built-in AI functionality, not the MCP server. [With the MCP server, your client provides the AI](mcp.md#with-the-mcp-server-your-client-provides-the-ai). + +### Metabase AI Service + +On Metabase Cloud, you can have us manage the AI for you with our AI Service. + +Metabase's AI Service is a good option if you don't have a preferred AI provider, or if you want to manage all your Metabase AI costs through Metabase. We (Metabase the company) select the models for you. We use internal benchmarks to determine which AI models work best for different tasks, and we're constantly iterating to improve performance. + +If you use Metabase's AI Service, you'll get charge based on token usage (in addition to your regular Metabase Cloud subscription fee). See [Pricing](https://www.metabase.com/pricing). + +To enable Metabase's AI Service on Metabase Cloud, you must me logged in to your Metabase instance with the email that matches the email for the admin of your [Metabase Store account](https://store.metabase.com). + +To use Metabase AI provider for your Metabot: + +1. Go to **Admin > AI > AI settings**. +2. In **Connect to an AI provider**, choose **Metabase** as the provider. +3. Agree to the terms of service. +4. Click **Connect**. + +To disable Metabase AI provider and stop charges: + +1. Go to **Admin > AI > AI settings**. +2. Under **Connected to Metabase**, click **Disconnect**. + +Any Metabase instance admin can disconnect the Metabase AI Service, even if they lack an admin account in the Metabase store. + +### Bring your own API key + +You can specify your own API key and model for Metabot from one of the supported providers. Currently, Metabase only supports models from Anthropic. + +If you're interested in Metabase supporting more AI providers, let us know by submitting a [feature request](../troubleshooting-guide/requesting-new-features.md). + To enable AI features with your own API key: 1. Go to **Admin > AI**. @@ -37,17 +72,13 @@ To enable AI features with your own API key: 4. Click **Connect**. 5. Select a **Model** from the dropdown. Available models are fetched from the provider using your API key. -When your connection is active, the provider card header shows **Connected to [provider]** (for example, "Connected to Anthropic") next to a green status dot. With your key connected, you get access to [Metabot](./metabot.md), [inline SQL generation](./metabot.md#inline-sql-editing), the [MCP server](./mcp.md), and the [Agent API](./agent-api.md). +When your connection is active, the provider card header shows **Connected to [provider]** (for example, "Connected to Anthropic") next to a green status dot. With your key connected, you get access to [Metabot](./metabot.md), and [inline SQL generation](./metabot.md#inline-sql-editing). To clear your provider connection, click **Disconnect**. Disconnecting removes the stored API key and turns off any AI features that depend on the provider. -### Supported providers - -Currently, Metabase only supports models from Anthropic. - ## Configure Metabot -_Admin > AI > Metabot settings_ +_Admin > AI > AI settings_ ![Metabot settings](./images/ai-settings.png) @@ -104,42 +135,6 @@ When people open a new Metabot chat, Metabase shows a few suggested prompts base Click **Regenerate suggested prompts** to generate a fresh set of prompts. You can also run individual prompts to test Metabot's answers, or delete prompts that aren't useful. The Internal and Embedded tabs each maintain their own set of suggestions, so regenerating on one tab doesn't affect the other. -## Enable MCP server - -_Admin > AI > MCP_ - -MCP server and Agent API settings live on their own subpage. From **Admin > AI**, open the **MCP** tab in the left sidebar. - -Use the **MCP server** toggle to turn external access to the [MCP server](./mcp.md) on or off. - -### Supported MCP clients - -Under **Supported MCP clients**, switch on any clients you want to allow: - -- **Claude** (Claude Desktop and Claude on the web) -- **Cursor and VS Code** -- **ChatGPT** - -Toggling on a client automatically adds that client's sandbox domains to Metabase's CORS allowlist, which is what lets browser-based MCP clients make cross-origin requests to your Metabase. - -Some clients run outside the browser (like Claude Code on your own machine) and don't need a CORS allowlist entry. You can connect those clients without toggling anything on (assuming you've turned on the main MCP server setting). - -### Custom MCP client domains - -If you run a self-hosted MCP client or one that isn't in the supported list, add its domain to the **Custom MCP client domains** field. Separate values with a space, for example: - -``` -https://mcp.internal.example.com https://*.staging.example.com -``` - -The field accepts wildcards (`*`) for subdomains. Changes take effect in about a minute. Might be a good time to get up and pour yourself a glass of water. - -## Agent API settings - -_Admin > AI > MCP_ - -Use the **Agent API** toggle to turn external access to the [Agent API](./agent-api.md) on or off. - ## Disable all AI features The **Disable all AI features** toggle at the bottom of the AI features page is a master kill switch. When turned on, it hides all AI features across your instance — Metabot, inline SQL generation, the MCP server, the Agent API, and any embedded chat components — regardless of the individual toggles above. @@ -190,19 +185,13 @@ In other words, to restrict what data Metabot can see for each person, simply ap ## Viewing Metabot usage -If you're using the Metabase AI service, you can see how many Metabot requests people have made this month by going to **Admin > Settings > License**. +If you're using the Metabase AI service, you can see how many Metabot requests people have made this month by going to **Admin > AI**. If you aren't logged into the [Metabase Store](../cloud/accounts-and-billing.md), you'll need to log in to the store before you can view the usage. Once logged in to the store, go back to your Metabase and view the license page. -The **Metabot AI requests used, this month (updated daily)** field shows how many requests your Metabase has used this month. Each message sent to Metabot counts as a request. - If you're using your own API key, you can track usage and costs through your AI provider's dashboard. -## Choosing the AI model - -If you're using your own API key, you can choose which AI model Metabase uses when you [bring your own API key](#bring-your-own-api-key). - -When using the Metabase AI service, Metabase selects models automatically. We use internal benchmarks to determine which AI models work best for different tasks, and we're constantly iterating to improve performance. +On Metabase Pro/Enterprise, you also get access to detailed [AI usage auditing](usage-auditing.md) with detailed breakdown of AI usage by user, tool, feature etc. ## Privacy @@ -220,4 +209,13 @@ Metabot has access to your Metabase metadata and some data values to help answer - **Sample field values**: When you ask questions like "Filter everyone from Wisconsin," Metabot might check the values in the state field to understand how the data is stored (like "WI" vs "Wisconsin"). See [syncs](../databases/sync-scan.md). - **Timeseries data**: For chart analysis, Metabot might see the timeseries data used to draw certain visualizations, depending on the chart type. -This data may be included when you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses). +When you [submit feedback](./metabot.md#giving-feedback-on-metabot-responses), the context for the conversation - including this metadata and conversation prompts - might be sent to Metabase. + +## Further reading + +- [Using Metabot](metabot.md) +- [MCP server](mcp.md) +- [AI access and usage controls](usage-controls.md) +- [AI usage auditing](usage-auditing.md) +- [Metabot customization](customization.md) +- [Metabot system prompts](system-prompts.md) diff --git a/docs/ai/start.md b/docs/ai/start.md index 2591093e3e1e..84fe839c7cb8 100644 --- a/docs/ai/start.md +++ b/docs/ai/start.md @@ -4,8 +4,6 @@ title: "AI in Metabase" # AI in Metabase -> AI features are available on [Metabase Cloud](https://www.metabase.com/features/metabot-ai) and on self-hosted Metabase, using either the Metabase AI service or your own AI provider API key. Enable them from **Admin settings > AI**. - ## [Metabot](./metabot.md) Metabot is an AI assistant that helps you explore and analyze your data. @@ -41,4 +39,3 @@ Chat with Metabot directly in Slack — ask questions, get charts, and manage su ## [AI usage auditing](./usage-auditing.md) See how people are using the AI features in your Metabase. - diff --git a/docs/configuring-metabase/environment-variables.md b/docs/configuring-metabase/environment-variables.md index b928959b94e6..ce10836af340 100644 --- a/docs/configuring-metabase/environment-variables.md +++ b/docs/configuring-metabase/environment-variables.md @@ -418,8 +418,7 @@ By default, this is 20 minutes. Timeout in minutes for the database's query execution, both for the Metabase application database and any data connections. If you have long-running queries, you might consider increasing this value. Adjusting the timeout does not impact Metabase’s frontend. - This setting also applies to individual queries executed within transforms, so make sure the duration is long enough - that it doesn't timeout any long-running queries in your transforms. + This setting does not apply to queries executed within transforms; those are governed by MB_TRANSFORM_TIMEOUT instead. Please be aware that other services (like Nginx) may still drop long-running queries. @@ -2163,8 +2162,9 @@ Timeout in milliseconds to wait after query cancellation before escalating to th The timeout for a transform job, in minutes. -Each query executed by a transform is also subject to the MB_DB_QUERY_TIMEOUT_MINUTES timeout, - so make sure that value isn't lower, or it will timeout your transform. +Controls the timeout for transform runs, including the queries they execute. This takes precedence + over MB_DB_QUERY_TIMEOUT_MINUTES for queries executed inside a transform, so transforms can run longer than regular + Metabase queries. ### `MB_TRANSFORMS_ENABLED` diff --git a/docs/configuring-metabase/settings.md b/docs/configuring-metabase/settings.md index 37e9f22415f5..a364f1fb96a6 100644 --- a/docs/configuring-metabase/settings.md +++ b/docs/configuring-metabase/settings.md @@ -11,7 +11,6 @@ _Admin > Settings > General_ This section contains settings for your whole instance, like its URL, the reporting timezone, and toggles for disabling or enabling some of Metabase's optional features. You can configure these settings by clicking the **grid icon** in the upper right, then going to **Admin** > **Settings** > **General**. - ## Site name How you’d like to refer to this instance of Metabase. @@ -40,10 +39,20 @@ To revert to the default Metabase homepage, simply toggle off Custom homepage. This email address will be displayed in various messages throughout Metabase when users encounter a scenario where they need assistance from an admin, such as a password reset request. -## Anonymous tracking +## Usage tracking + +### Send anonymous tracking data to Metabase On self-hosted Metabases, this option determines whether or not you allow [anonymous data about your usage of Metabase](../installation-and-operation/information-collection.md) to be sent back to us to help us improve the product. [Your database’s data is never tracked or sent](https://www.metabase.com/security). +### Collect user data to display in usage analytics + +{% include plans-blockquote.html feature="Collecting user data" %} + +You can switch on logging of IP addresses, user agents, embed path, query parameters, and Metabot conversation metadata for people using your Metabase, both for people directly by logging into Metabase, or for people who view an embedded Metabase component in your app. If enabled, you can find this information in your [usage analytics](../usage-and-performance-tools/usage-analytics.md). + +By default, collection of user data is turned **off**. + ## Friendly table and field names By default, Metabase attempts to make field and table names more readable by changing things like `somehorriblename` to `Some Horrible Name`. This does not work well for languages other than English, or for fields that have lots of abbreviations or codes in them. If you'd like to turn this setting off, you can do so from the Admin Panel under **Admin** **> Settings** > **General**. diff --git a/docs/data-modeling/model-persistence.md b/docs/data-modeling/model-persistence.md index 8060ec7147c0..9f3ce2f35f23 100644 --- a/docs/data-modeling/model-persistence.md +++ b/docs/data-modeling/model-persistence.md @@ -4,7 +4,7 @@ title: Model persistence # Model persistence -> Prefer [Transforms](../data-studio/transforms/transforms-overview.md) instead of model persistence. Model persistence remains supported for now, but it will be deprecated in future versions of Metabase. You can [convert models to transforms in bulk](../data-studio/transforms/transforms-overview.md#convert-models-to-transforms). +> Prefer [Transforms](../data-studio/transforms/transforms-overview.md) instead of model persistence. Model persistence remains supported for now, but it will be deprecated in future versions of Metabase. You can [convert models to transforms in bulk](../data-studio/transforms/query-transforms.md#convert-models-to-transforms). Metabase can persist the results of your models so that your models (and the questions based on those models) load faster. diff --git a/docs/data-modeling/models.md b/docs/data-modeling/models.md index ccbdf421ecdf..e658380f51a8 100644 --- a/docs/data-modeling/models.md +++ b/docs/data-modeling/models.md @@ -6,7 +6,7 @@ redirect_from: # Models -> Consider using [Transforms](../data-studio/transforms/transforms-overview.md) instead of models. You can [convert models to transforms in bulk](../data-studio/transforms/transforms-overview.md#convert-models-to-transforms). +> Consider using [Transforms](../data-studio/transforms/transforms-overview.md) instead of models. You can [convert models to transforms in bulk](../data-studio/transforms/query-transforms.md#convert-models-to-transforms). Models curate data from another table or tables from the same database to anticipate the kinds of questions people will ask of the data. You can think of them as derived tables, or a special kind of saved question meant to be used as the starting point for new questions. You can base a model on a SQL or query builder question, which means you can include custom, calculated columns in your model. @@ -224,7 +224,7 @@ See [Model persistence](./model-persistence.md) If you're an admin, you can convert existing models to transforms one at a time. Conversion creates a transform from the model's query, runs it to produce the output table, and then updates all questions and dashboards that used the model to use the transform's table instead. The model itself becomes a saved question. -See [Convert existing models to transforms](../data-studio/transforms/transforms-overview.md#convert-models-to-transforms). +See [Convert existing models to transforms](../data-studio/transforms/query-transforms.md#convert-models-to-transforms). ## Further reading diff --git a/docs/data-studio/images/disable-all-jobs.png b/docs/data-studio/images/disable-all-jobs.png new file mode 100644 index 000000000000..61bc13e838c0 Binary files /dev/null and b/docs/data-studio/images/disable-all-jobs.png differ diff --git a/docs/data-studio/images/jobs-schedule.png b/docs/data-studio/images/jobs-schedule.png new file mode 100644 index 000000000000..57329bd6cac7 Binary files /dev/null and b/docs/data-studio/images/jobs-schedule.png differ diff --git a/docs/data-studio/transforms/addons.md b/docs/data-studio/transforms/addons.md new file mode 100644 index 000000000000..dc51ee53a46e --- /dev/null +++ b/docs/data-studio/transforms/addons.md @@ -0,0 +1,104 @@ +--- +title: Transform add-ons +summary: Metabase transforms come in two flavors - basic transforms for basic query-based functionality, and advanced transforms for Python workflows, transform inspector, and other functionality. +--- + +# Transform add-ons + +At a glance: + +[**Basic transforms**](#basic-transforms): + +- Run [query-based transforms](query-transforms.md) +- Schedule transform [jobs](jobs-and-runs.md). +- (Pro/Enterprise only) Configure [permissions for transforms](transforms-overview.md#permissions-for-transforms). + +[**Advanced transforms**](#advanced-transforms): + +- Run [query-based](query-transforms.md) and [Python transforms](python-transforms.md); +- Schedule transform [jobs](jobs-and-runs.md). +- (Pro/Enterprise only) Configure [permissions for transforms](transforms-overview.md#permissions-for-transforms). +- [Writable connection](../../databases/writable-connection.md): separate database connection used for write operations. +- [Transform inspector](transform-inspector.md). + +Availability and pricing depends on your plan and hosting method. + +## Basic transforms + +With basic transforms, you can: + +- Write and run [query-based transforms](query-transforms.md) (but not Python transforms). +- [Schedule and run jobs](jobs-and-runs.md). +- (Pro/Enterprise only) Configure [permissions for transforms](transforms-overview.md#permissions-for-transforms). + +### Enable basic transforms + +- **Self-hosted Metabases**: Basic transform functionality is included on self-hosted Metabases by default. Just log into your Metabase, [enable transforms](../transforms/transforms-overview.md#enable-transforms), and you're good to go. + +- **Metabase Cloud**: Basic transform functionality on Metabase Cloud - Starter, Pro, or Enterprise - comes with an additional small fee per successful transform run, see [Pricing](https://www.metabase.com/pricing). + + Only people logged in with an email of a [Metabase Store admins](../../cloud/accounts-and-billing.md#add-people-to-manage-your-metabase-store-account) (not just Metabase _instance_ admins) can enable basic transforms. To enable Basic transforms on Metabase Cloud, see [Enable transforms](./transforms-overview.md#enable-transforms). + +### Cancel basic transforms + +Once basic transforms are enabled on your Metabase Cloud instance, they can't be disabled. + +## Advanced transforms + +Advanced transforms include: + +- Everything in [basic transforms](#basic-transforms). +- [Python transforms](python-transforms.md) for more flexible data processing. +- [Writable connection](../../databases/writable-connection.md): separate database connection used for write operations. +- [Transform inspector](transform-inspector.md). + +You can buy the Advanced transforms add-on for: + +- Any Metabase Cloud instance (Starter, Pro, Enterprise) +- Self-hosted Pro or Enterprise instances. + + Currently, you can't use Advanced transforms functionality on Open Source self-hosted plans. + +The Advanced transforms add-on comes with an additional charge per successful transform run (compare to [Basic transforms](#basic-transforms)). See [Pricing](https://www.metabase.com/pricing). + +### Enable Advanced transforms + +To enable Advanced transforms functionality, you need to have [Basic transforms](#basic-transforms) already, see [Enable basic transforms](#enable-basic-transforms). + +There are two ways to enable Advanced transforms: + +- **From your Metabase instance**: you can navigate to a feature requiring advanced transforms (like Python transforms or transform inspector), and follow the prompts to upgrade. + + To enable Advanced transforms from your Metabase instance, you need to be logged into the instance with the same email as a [Metabase Store admin](../../cloud/accounts-and-billing.md#add-people-to-manage-your-metabase-store-account), because Advanced transform incur an additional charge. + +- **From [Metabase Store](https://store.metabase.com)**: + + 1. Log into [Metabase Store](https://store.metabase.com) (your Metabase Store account might be different from your Metabase instance account). + 2. Click on **Manage plan** next to the instance where you'd like to add Advanced transforms. + 3. Under **Manage Add-ons**, find **Advanced transforms** and click **Upgrade**. + +Once you upgrade to Advanced transforms: + +- You get access to Advanced transforms features/ +- All your existing query-based transforms will be charged at Advanced transforms rate. + +### Cancel Advanced transforms + +You can downgrade from Advanced transforms to [Basic transforms](#basic-transforms). + +1. Log into [Metabase Store](https://store.metabase.com) (your Metabase Store account might be different from your Metabase instance account). +2. Click on **Manage plan** next to the instance with Advanced transforms. +3. Under **Manage Add-ons**, find **Advanced transforms**, click **three dos** and select **Downgrade to basic**. + +Once you downgrade from advanced transforms: + +- Your Python transforms will no longer run and will be removed. +- Transforms and other write features will stop using the writable connection (if you had one configured.) + +## How billing works for transforms + +Unless you're on an Enterprise plan, transforms - either basic (on Metabase Cloud) or advanced - are billed based on the number of successful runs. See [Jobs and runs](jobs-and-runs.md) for more on transform runs, and [Pricing](https://www.metabase.com/pricing) for up-to-date information on pricing. + +When you upgrade from basic to advanced transforms, _all_ your transforms will be billed at advanced transforms rate. + +If you're on an Enterprise plan and have questions about billing, [contact us](https://www.metabase.com/help-premium). diff --git a/docs/data-studio/transforms/jobs-and-runs.md b/docs/data-studio/transforms/jobs-and-runs.md index 5de28bba3039..3ebb6229080d 100644 --- a/docs/data-studio/transforms/jobs-and-runs.md +++ b/docs/data-studio/transforms/jobs-and-runs.md @@ -33,16 +33,76 @@ _Data Studio > Jobs_ Jobs run one or more transforms on schedule based on transform tags. -To see all jobs, go to **Data Studio** and click on the **Jobs** at the bottom of the left sidebar. - -To create a new job, go to **Data Studio > Jobs**, and click on the **+ New** button in the top right. - Jobs have two components: schedule and tags. - **Schedule** determines when the job will be executed: daily, hourly, etc. You can specify a custom cron schedule (e.g. "Every weekday at 9:05 AM"). The times are given in your Metabase's system timezone. - **Tags** determine _which_ transforms a job runs, not when the job runs. For example, you can create a `Weekdays` tag, add that tag to a few transforms, then create a job that runs all the transforms with the `Weekdays` tag every weekday at 9:05AM. -Job can use multiple tags, and will run all transforms that have _any_ of those tags. For example, you can have a job "Weekend job" that is scheduled run at noon on Saturdays and Sundays that picks up all transforms tagged either "Saturday", "Sunday", or "Weekend". +Jobs will run all transforms tagged with any of the tags, plus any transforms that the tagged transforms depend on, see [Jobs will run all dependent transforms](#jobs-will-run-all-dependent-transforms). + +You can see which transforms a job will run and in which order on the job's page. + +### See all jobs + +To see all jobs, go to **Data Studio** and click the **Jobs** at the bottom of the left sidebar. + +### Create a job + +To create a new job: + +1. Go to **Data Studio > Jobs** +2. Click the **+ New** button in the top right. +3. Specify the schedule: select one of the built-in schedules or use cron syntax to specify a custom schedule, + + ![Jobs schedule](../images/jobs-schedule.png) + + Job can use multiple tags, and will run all transforms that have _any_ of those tags. For example, you can have a job "Weekend job" that is scheduled run at noon on Saturdays and Sundays that picks up all transforms tagged either "Saturday", "Sunday", or "Weekend". + +### Disable jobs + +You can disable jobs without deleting them. Unlike deletion (which is permanent), disabling a job just means it won't run until you re-enable it. This is useful when you want to temporarily stop transforms from running - for example, for debugging purposes. This way you don't your lose configuration settings like tags and schedules. + +To disable a specific job: + +1. Go to **Data studio > Jobs**. +2. Find the job you want to disable and click the **three dots** icon to the right of the job's name. +3. Select **Disable** + +To disable all jobs: + +1. Go to **Data studio > Jobs**. +2. Click the **three dots** icon above the table with all the jobs, and select **Disable all**. + + ![Disable all jobs](../images/disable-all-jobs.png) + +Even if you disable all jobs, new jobs will still be created enabled by default. + +### Re-enable jobs + +If you [disabled any jobs](#disable-jobs), you can later re-enable them: + +To re-enable a specific job: + +1. Go to **Data studio > Jobs**. +2. Find the job you want to re-enable and click the **three dots** icon to the right of the job's name. +3. Select **Re-enable** + +To re-enable all jobs: + +1. Go to **Data studio > Jobs**. +2. Click the **three dots** icon above the table with all the jobs, and select **Re-enable all**. + +### Delete a job + +Deleting a job will not delete any transforms. + +Deleted jobs can't be restored. If you want to temporarily stop a job from running, consider disabling the job instead. + +To delete a job: + +1. Go to **Data Studio > Jobs**. +2. Find the job you want to delete and click the **three dots** icon to the right of the job's name. +3. Select **Delete**. ## Jobs will run all dependent transforms diff --git a/docs/data-studio/transforms/python-runner.md b/docs/data-studio/transforms/python-runner.md index c0024289ada4..0bde1cdc1e09 100644 --- a/docs/data-studio/transforms/python-runner.md +++ b/docs/data-studio/transforms/python-runner.md @@ -5,7 +5,7 @@ summary: Configure self-hosted Python execution environment to run Python transf # Python runner -> Self-hosted Python transforms require self-hosted Pro or Enterprise plan with the **Advanced transforms** add-on. +> Self-hosted Python transforms require self-hosted Pro or Enterprise plan with the [Advanced transforms add-on](addons.md). To run Python transforms from a self-hosted Metabase, you'll need to configure a separate self-hosted execution environment. If you're using Metabase Cloud, you'll need to buy the Transforms add-on. @@ -95,7 +95,7 @@ docker run -d \ ### Metabase environment variables -These settings can also be configured in the Metabase UI at **Admin** > **Settings** > **Python Runner** (`/admin/settings/python-runner`). Note that environment variables take precedence over UI settings. +These settings can also be configured in the Metabase UI at **Admin** > **Settings** > **Python Runner**. Note that environment variables take precedence over UI settings. | Variable | Description | | ----------------------------------------- | ---------------------------------------------------------------------------------- | @@ -108,8 +108,6 @@ These settings can also be configured in the Metabase UI at **Admin** > **Settin | `MB_PYTHON_STORAGE_S_3_SECRET_KEY` | S3 secret key | | `MB_PYTHON_STORAGE_S_3_PATH_STYLE_ACCESS` | (Optional) Set to `true` for S3-compatible services like MinIO or LocalStack | ---- - ## Using Docker Compose Docker Compose simplifies managing multiple containers. Below are example configurations for different scenarios. diff --git a/docs/data-studio/transforms/python-transforms.md b/docs/data-studio/transforms/python-transforms.md index aff313289ee7..4d1de8d50cd1 100644 --- a/docs/data-studio/transforms/python-transforms.md +++ b/docs/data-studio/transforms/python-transforms.md @@ -5,7 +5,7 @@ summary: Use Python to wrangle your data in Metabase and write the results back # Python transforms -> Python transforms require the **Transforms add-on**. +> Python transforms require the [Advanced transforms add-on](addons.md) Use Python to write [transforms](transforms-overview.md). diff --git a/docs/data-studio/transforms/query-transforms.md b/docs/data-studio/transforms/query-transforms.md index 976ce6eb835c..e8b05f1290e6 100644 --- a/docs/data-studio/transforms/query-transforms.md +++ b/docs/data-studio/transforms/query-transforms.md @@ -5,7 +5,7 @@ summary: Create Metabase questions and SQL queries to transform your data and wr # Query-based transforms -> On Metabase Cloud, you need the **Transforms** add-on to run query-based transforms. +> On Metabase Cloud, you need the [**Basic transforms** add-on](addons.md) to run query-based transforms. With query-based transforms, you can write a query in SQL or Metabase's query builder, and then write the results of the query back into the database on schedule. @@ -23,23 +23,24 @@ For general information about Metabase transforms, see [Transforms](transforms-o ## Create a query-based transform -On Metabase Cloud, you need the **Transforms** add-on to create query-based transforms. +On Metabase Cloud, you need the [**Basic transforms** add-on](addons.md) to create query-based transforms. -1. Go to **Data studio > Transforms**. +1. [Enable transforms](transforms-overview.md#enable-transforms). +2. Go to **Data studio > Transforms**. -2. Click **+ New** and pick "Query builder", "SQL", or "Copy of existing question". +3. Click **+ New** and pick "Query builder", "SQL", or "Copy of existing question". Currently, you can't convert between different transform types (like converting a query builder transform to a SQL-based transform, or a SQL transform into a Python transform). If you want to change your transform built with the query builder into a SQL transform, you'll need to create a new transform with the same target and tags, and delete the old transform. -3. Write your transform query as you would normally write a query in Metabase. See [Query builder](../../questions/query-builder/editor.md) and [SQL editor](../../questions/native-editor/writing-sql.md) documentation for more information. +4. Write your transform query as you would normally write a query in Metabase. See [Query builder](../../questions/query-builder/editor.md) and [SQL editor](../../questions/native-editor/writing-sql.md) documentation for more information. Not all databases support transforms, see [Databases that support transforms](transforms-overview.md#databases-that-support-transforms). -4. To test your transform, press the **Run** button at the bottom of the editor. +5. To test your transform, press the **Run** button at the bottom of the editor. Previewing a query transform in the editor will _not_ write the result of the transform back to the database. -5. Click **Save** in the top right corner and fill out the transform information: +6. Click **Save** in the top right corner and fill out the transform information: - **Name** (required): The name of the transform. - **Schema** (required): Target schema for your transform. This schema can be different from the schema of the source table(s). You create a new schema by typing its name in this field. You can only transform data _within_ a database; you can't write from one database to another. @@ -47,7 +48,7 @@ On Metabase Cloud, you need the **Transforms** add-on to create query-based tran - **Folder** (optional): The folder where the transform should live. Click on the field to pick a different folder or create a new one. - **Incremental transformation** (optional): see [Incremental query transforms](#incremental-query-transforms) -6. Optionally, assign tags to your transforms. Tags are used by [jobs](jobs-and-runs.md) to run transforms on schedule. +7. Optionally, assign tags to your transforms. Tags are used by [jobs](jobs-and-runs.md) to run transforms on schedule. ## Variables in SQL transforms @@ -85,8 +86,6 @@ Parameters in transforms must either: The reason transform variables must have a default value (or be optional) is that transforms run on a schedule, so there's no way to pass a value to the variable when the job runs the transform. -The incremental `{%raw%}[[WHERE id > {{checkpoint}}]]{% endraw %}` pattern shown in [Incremental query transforms](#incremental-query-transforms) is an example of this an optional variable in practice. See also [optional variables](../../questions/native-editor/optional-variables.md). - ## Run a query transform See [Run a transform](transforms-overview.md#run-a-transform). You'll see logs for a transform run on the transform's page. @@ -127,8 +126,8 @@ To make this transform incrementally load the data based on new values of `order 1. Add a table variable, for example `{{orders_var}}` replacing `orders` in the `FROM` statement; 2. In the table variable settings, connect the table variable to the `orders` table; 3. Replace other references to the table in your query with either: - - The name of the table variable (if you have "Emit table alias" toggled on in variable's setting). - - Your own handcrafted alias for the variable. + - The name of the table variable (if you have "Emit table alias" toggled on in variable's setting). + - Your own handcrafted alias for the variable. So your query will look like this: @@ -152,3 +151,36 @@ To make a query transform incremental: 1. Go to the transform's page in **Data studio > Transforms**. 2. Switch to **Settings** tab. 3. In **Column to check for new values**, select the column in one of the source tables that Metabase should check to determine which values are new. Only some columns are eligible. See [prerequisites for incremental transforms](./transforms-overview.md#prerequisites-for-incremental-transforms). + +## Convert models to transforms + +{% include plans-blockquote.html feature="Converting models to transforms"%} + +If you have models you'd like to migrate to transforms, Metabase can convert them for you. When you convert a model, Metabase: + +1. Creates a new transform based on the model's query. +2. Runs the transform to create the output table in your database. +3. Replaces every question, dashboard, and other item that used the model with the transform's output table. +4. Converts the original model to a question. + +You must be an admin to convert models, and the model's database must [support transforms](transforms-overview.md#databases-that-support-transforms). + +To convert a model into a transform: + +1. Review [Replacing data sources](../dependencies/replace-data-sources.md) docs for overview and limitations of the process. +2. Open **Data Studio** and select **Transforms** in the sidebar. +3. Click **Tools > Migrate models**. +4. Find the model you want to convert and click it to open its details panel. The panel shows the model's name, database, and collection, and a list of items that depend on it. +5. Click **Convert to a transform**. +6. Fill out the transform settings. See [Create a transform](#create-a-query-based-transform) for the overview of settings. +7. Click **Convert to a transform**. + +Metabase runs the conversion in the background. A status indicator at the bottom of the screen shows progress. + +If the transform run fails, Metabase stops and leaves the model unchanged—nothing is replaced. + +If the transform runs successfully, but the source swap fails afterward, the transform and its output table are kept. The model is left unchanged. You can complete the migration manually using [Replace data sources](../dependencies/replace-data-sources.md) to point the remaining content from the model to the transform's output table. + +Once conversion completes, all content that previously queried the model now queries the transform's output table. The original model just becomes a question. + +Newly created tables will be created with default permissions and will _not_ inherit the model's permissions. As an alternative, consider manually creating and running the transform first, setting up the permissions, then using [Replace data sources](../dependencies/replace-data-sources.md) to swap the model for the transform's output table once you configured permissions. diff --git a/docs/data-studio/transforms/transform-inspector.md b/docs/data-studio/transforms/transform-inspector.md index 24b76950cd9c..386400e568ce 100644 --- a/docs/data-studio/transforms/transform-inspector.md +++ b/docs/data-studio/transforms/transform-inspector.md @@ -5,7 +5,7 @@ summary: Analyze how your transforms process data by inspecting input and output # Transform inspector -> Transform inspector requires the **Advanced transforms** add-on. +> Transform inspector requires the [Advanced transforms add-on](addons.md). _Data Studio > Transforms > [transform name] > Inspect_ diff --git a/docs/data-studio/transforms/transforms-overview.md b/docs/data-studio/transforms/transforms-overview.md index 13bd2aa139f4..d0e50daaa9e9 100644 --- a/docs/data-studio/transforms/transforms-overview.md +++ b/docs/data-studio/transforms/transforms-overview.md @@ -36,7 +36,7 @@ Currently, Metabase can create transforms on the following databases: You can't create transforms on databases that have [Database routing](../../permissions/database-routing.md) enabled, or on Metabase's Sample Database. -Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) option for your database. +Transforms will create tables in your database, so the database user you use for your connection must have appropriate privileges. See [Database users, roles, and privileges](../../databases/users-roles-privileges.md). We suggest using a [Writable connection](../../databases/writable-connection.md) for your database. ## Types of transforms @@ -47,22 +47,39 @@ Metabase supports two types of transforms: query-based transforms and Python tra ## Permissions for transforms -If you are running Metabase Open Source/Starter, Admins (and only Admins) can see and run transforms. +Permission configuration for transform depends on your plan. -Metabase Pro/Enterprise comes with additional permission controls for transforms: +- **Metabase Open Source/Starter**: Admins (and only Admins) can see and run transforms. -- To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md). -- To **execute** transforms on a database, people additionally need to have the [Transform permissions](../../permissions/data.md) for that database. +- **Metabase Pro/Enterprise** comes with additional permission controls for transforms: a special [Data Analysts](../../people-and-groups/managing.md) group for non-Admins with potential transform access, and granular transform permissions for each database: + + - To **see** the list of transforms on your instance, people need to be able to access Data Studio, so they need to be either an Admin or a member of the special [Data Analyst group](../../people-and-groups/managing.md). + - To **execute** transforms on a database, people need to be either Admins on belong to the special [Data Analyst group](../../people-and-groups/managing.md). Additionally people need to have the [Transform permissions](../../permissions/data.md) for that database. + +## Enable transforms + +Before you can start writing transforms, you'll need to enable transforms in your Metabase instance. + +If you are on a Metabase Cloud plan, only people logged in with an email of a [Metabase Store admins](../../cloud/accounts-and-billing.md#add-people-to-manage-your-metabase-store-account) (not just Metabase _instance_ admins) can enable basic transforms, because transforms incur a cost per run on Metabase Cloud. + +To enable transforms: + +1. Navigate to [**Data Studio**](../overview.md) by click the **grid icon** in top right corner of your Metabase and selecing **Data Studio**. +2. In Data Studio, click **Transforms** in the right sidebar. +3. If the transforms have not been enabled on your instance yet, you'll see a prompt to enable them. You can do just that. + +Once you've enabled transforms, you can [configure permissions](#permissions-for-transforms) and start [creating transforms](#create-a-transform). ## See all transforms _Data Studio > Transforms_ +See [permissions needed to see transforms](#permissions-for-transforms). + You can see all your Metabase's transforms: -1. Make sure you have [appropriate permissions to see transforms](#permissions-for-transforms). -2. Click the **grid** icon on top right and go to **Data Studio**. -3. In the left sidebar, select **Transforms**. +1. Click the **grid** icon on top right and go to **Data Studio**. +2. In the left sidebar, select **Transforms**. ![Transforms](../images/transforms.png) @@ -70,11 +87,13 @@ You can see all your Metabase's transforms: _Data Studio > Transforms_ -If you're using remote sync, you won't be able to create transforms if your instance is in ["read-only" sync mode](../../installation-and-operation/remote-sync.md). +> If you're using remote sync, you won't be able to create transforms if your instance is in ["read-only" sync mode](../../installation-and-operation/remote-sync.md). + +See [permissions needed to create transforms](#permissions-for-transforms). To create a transform: -1. Make sure you have [appropriate permissions for creating transforms](#permissions-for-transforms). +1. [Enable transforms](#enable-transforms). 2. Click the **grid** icon on top right and go to **Data Studio**. 3. In the left sidebar, select **Transforms**. 4. Click **+ New** and select a source for your transform. @@ -111,7 +130,7 @@ To create a transform: ## Use Metabot to generate code for transforms -> Code generation for transforms requires [Metabot to be enabled](../../ai/settings.md#enable-ai-features) and the **Transforms** add-on. +> Code generation for transforms requires [AI features](../../ai/settings.md#enable-ai-features). You can ask Metabot to generate a new SQL or Python-based transform, or edit an existing transform. @@ -145,12 +164,13 @@ If you're using remote sync, you won't be able to edit transforms if your instan _Data Studio > Transforms > Definition_ +See [permissions to edit transforms](#permissions-for-transforms). + To edit the transform's query or script: -1. Make sure you have [permissions to edit transforms](../../permissions/data.md). -2. Go to **Data Studio > Transforms**. -3. Find the transform you'd like to edit and click on **Edit definition** above the transform definition. -4. Edit the query or script. +1. Go to **Data Studio > Transforms**. +2. Find the transform you'd like to edit and click on **Edit definition** above the transform definition. +3. Edit the query or script. See [query-based transforms](query-transforms.md) and [Python transforms](python-transforms.md) for more information. You can [use Metabot](#use-metabot-to-generate-code-for-transforms) to help edit your transform. @@ -168,11 +188,11 @@ To edit transform's target table, i.e., the table where the query results are wr You can run a transform manually or schedule the transform using tags and jobs. -Running a transform for the first time will create and sync the table created by the transform, and you'll be able to edit the table's [metadata](../../data-modeling/metadata-editing.md) and [permissions](../../permissions/data.md). Subsequent runs will drop and recreate the table, unless you use [Incremental transforms](#incremental-transforms). +- To run a transform manually, visit the transform in **Data Studio > Transforms > Runs** and click **Run**. -To run a transform manually, visit the transform in **Data Studio > Transforms > Runs** and click **Run**. +- To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](./jobs-and-runs.md) that picks up those tags. -To schedule a transform, you'll need to assign one or more tags to it, then create a [scheduled job](./jobs-and-runs.md) that picks up those tags. +Running a transform for the first time will create and sync the table created by the transform, and you'll be able to edit the table's [metadata](../../data-modeling/metadata-editing.md) and [permissions](../../permissions/data.md). Subsequent runs will drop and recreate the table, unless you use [Incremental transforms](#incremental-transforms). You can see the time and status of the latest transform run on the transform's page, or in the [Runs view](./jobs-and-runs.md). The time of the run is given in the system's timezone. @@ -182,6 +202,8 @@ For Python transforms, you'll also see the transform's execution logs. _Data Studio > Transforms > [transform name] > Inspect_ +> Transform inspector requires the [Advanced transforms add-on](addons.md) + The [transform inspector](./transform-inspector.md) lets you poke at the input and outputs of your transform. ## Transform dependencies @@ -200,7 +222,7 @@ On Metabase Pro or Enterprise plans, you can see the transform dependencies grap If a job includes a transform that depends on a table created by another transform, then the job will run all the tagged transforms and their dependencies, even if they lack tags, see [Jobs and runs](jobs-and-runs.md) for more information. -### Incremental transforms +## Incremental transforms _Data Studio > Transforms > Settings_ @@ -220,6 +242,8 @@ Incremental transforms work differently for query-based transforms and Python tr _Admin > General > Remote sync_ +{% include plans-blockquote.html feature="Versioning transforms" %} + You can check your transforms into git with [Remote Sync](../../installation-and-operation/remote-sync.md). If you enable transform sync, Metabase will serialize transforms as YAML files and push them to your specified GitHub repo branch. To enable git sync of transforms: @@ -241,34 +265,4 @@ Transforms are similar to models with model persistence turned on, but there are Use models to enable non-admins to create their own datasets within Metabase, and to add context like field descriptions and semantic types. Use transforms to create persisted datasets in your database and reuse them across Metabase. In future versions of Metabase, model persistence will be deprecated in favor of transforms. -## Convert models to transforms - -If you have models you'd like to migrate to transforms, Metabase can convert them for you. When you convert a model, Metabase: - -1. Creates a new transform based on the model's query. -2. Runs the transform to create the output table in your database. -3. Replaces every question, dashboard, and other item that used the model with the transform's output table. -4. Converts the original model to a question. - -You must be an admin to convert models, and the model's database must [support transforms](#databases-that-support-transforms). - -Before converting models into transforms, review [Replacing data sources](../dependencies/replace-data-sources.md) docs for overview and limitations of the process. - -To convert a model into a transform: - -1. Open **Data Studio** and select **Transforms** in the sidebar. -2. Click **Tools > Migrate models**. -3. Find the model you want to convert and click it to open its details panel. The panel shows the model's name, database, and collection, and a list of items that depend on it. -4. Click **Convert to a transform**. -5. Fill out the transform settings. See [Create a transform](#create-a-transform) for the overview of settings. -6. Click **Convert to a transform**. - -Metabase runs the conversion in the background. A status indicator at the bottom of the screen shows progress. - -If the transform run fails, Metabase stops and leaves the model unchanged—nothing is replaced. - -If the transform runs successfully but the source swap fails afterward, the transform and its output table are kept. The model is left unchanged. You can complete the migration manually using [Replace data sources](../dependencies/replace-data-sources.md) to point remaining content from the model to the transform's output table. - -Once conversion completes, all content that previously queried the model now queries the transform's output table, and the model becomes a saved question. - -Newly created tables will be created with default permissions and will _not_ inherit the model's permissions. As an alternative, consider manually creating and running the transform first, setting up the permissions, then using [Replace data sources](../dependencies/replace-data-sources.md) to swap the model for the transform's output table once you configured permissions. +On Metabase Pro/Enterprise plans, you can convert Metabase models to transforms in bulk, see [Convert models to transforms](query-transforms.md#convert-models-to-transforms) diff --git a/docs/databases/writable-connection.md b/docs/databases/writable-connection.md index f51a5902878f..ac98bca720e2 100644 --- a/docs/databases/writable-connection.md +++ b/docs/databases/writable-connection.md @@ -5,9 +5,9 @@ redirect_from: - /docs/latest/databases/writeable-connection --- -## Writable connection +# Writable connection -> Writable connection requires the **Advanced transforms (SQL + Python)** add-on +> Writable connection requires the [Advanced transforms add-on](../data-studio/transforms/addons.md) _Admin > Databases > Writable connection_ diff --git a/docs/embedding/full-app-embedding.md b/docs/embedding/full-app-embedding.md index 696632c3705d..7cc2e5dea127 100644 --- a/docs/embedding/full-app-embedding.md +++ b/docs/embedding/full-app-embedding.md @@ -165,8 +165,6 @@ To manually log someone out of Metabase, load the following URL (for example, in https://metabase.yourcompany.com/auth/logout ``` -If you're using [JWT](../people-and-groups/authenticating-with-jwt.md) for SSO, we recommend setting the `exp` (expiration time) property to a short duration (e.g., 1 minute). - ## Supported postMessage messages _from_ embedded Metabase To keep up with changes to an embedded Metabase URL (for example, when a filter is applied), set up your app to listen for "location" messages from the embedded Metabase. If you want to use this message for deep-linking, note that "location" mirrors "window.location". diff --git a/docs/embedding/introduction.md b/docs/embedding/introduction.md index af7b66f29e13..c19d04debd32 100644 --- a/docs/embedding/introduction.md +++ b/docs/embedding/introduction.md @@ -94,6 +94,12 @@ The modular embeds that you can set up in the [in-app wizard](./modular-embeddin If you're using an AI agent to help you embed Metabase in your app, check out [AI agent resources](./ai-agent-resources.md). +## Tracking embed usage + +{% include plans-blockquote.html feature="Tracking embed usage" %} + +[Usage Analytics](../usage-and-performance-tools/usage-analytics.md) tracks embed usage, including embedding context, authentication methods, hostname, and other metadata. Check out the [Embedding usage dashboard](../usage-and-performance-tools/usage-analytics-reference.md#embedding-usage). + ## Further reading - [Strategies for delivering customer-facing analytics](https://www.metabase.com/learn/metabase-basics/embedding/overview). diff --git a/docs/embedding/sdk/introduction.md b/docs/embedding/sdk/introduction.md index 3fc9725aec34..84ebc14e3cc4 100644 --- a/docs/embedding/sdk/introduction.md +++ b/docs/embedding/sdk/introduction.md @@ -97,7 +97,7 @@ To enforce a single `@types/react` version across all dependencies, add an `over Starting with Metabase 57, the SDK consists of two parts: - **SDK Package** – The `@metabase/embedding-sdk-react` npm package is a lightweight bootstrapper library. Its primary purpose is to load and run the main SDK Bundle code. -- **SDK Bundle** – The full SDK code, served directly from your self-hosted Metabase instance or Metabase Cloud, and it's the part of the Metabase. This ensures that the main SDK code is always compatible with its corresponding Metabase instance. +- **SDK Bundle** – The full SDK code, served directly from your self-hosted Metabase instance or Metabase Cloud, and it's a part of Metabase. This ensures that the main SDK code is always compatible with its corresponding Metabase instance. ## Developing with the modular embedding SDK diff --git a/docs/installation-and-operation/observability-with-prometheus.md b/docs/installation-and-operation/observability-with-prometheus.md index 80668414b23a..08627a077394 100644 --- a/docs/installation-and-operation/observability-with-prometheus.md +++ b/docs/installation-and-operation/observability-with-prometheus.md @@ -162,6 +162,8 @@ Metrics exported by Metabase include: - `metabase_email_messages_created` - `metabase_email_message_errors_total` - `metabase_email_message_errors_created` +- `metabase_security_center_last_sync_timestamp_seconds` +- `metabase_security_center_vulnerable_advisories` ## Further reading diff --git a/docs/installation-and-operation/remote-sync.md b/docs/installation-and-operation/remote-sync.md index c430c74acbd6..4046e568a327 100644 --- a/docs/installation-and-operation/remote-sync.md +++ b/docs/installation-and-operation/remote-sync.md @@ -98,7 +98,19 @@ Create a personal access token with read and write repository access. Copy the t - **GitHub**: Create a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with **Contents: Read and write** and **Metadata: Read-only** permissions. - **GitLab**: In **User Settings** > **Personal access tokens**, [create a token](https://docs.gitlab.com/user/profile/personal_access_tokens/) with **read_repository** and **write_repository** scopes. Tokens have an expiration date (your admin may set a maximum lifetime). -- **Bitbucket**: In your [Atlassian account settings](https://id.atlassian.com/manage-profile/security) under **Security**, click **Create API token with scopes**. Select Bitbucket, search for "repos", and grant `read:repository:bitbucket` and `write:repository:bitbucket` (write does not include read). Tokens expire and can't be modified after creation. +- **Bitbucket**: In your Bitbucket repository, go to **Repository settings** > **Security** > **Access tokens** and click **Create access token**. Give it a name and, under **Scopes**, select: + + - **Repositories**: **Read** and **Write** + - **Pull requests**: **Read** and **Write** + + Click **Create** and copy the token immediately, you can't view the token again after closing the dialog. + + The created token must have these scopes (visible in the **View access token** dialog after creation): + + - `repository` + - `repository:write` + - `pullrequest` + - `pullrequest:write` ### 3. Connect your development Metabase to your repository @@ -152,7 +164,17 @@ Create a personal access token with read-only repository access. Copy the token - **GitHub**: Create a [fine-grained personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with **Contents: Read-only** and **Metadata: Read-only** permissions. - **GitLab**: In **User Settings** > **Personal access tokens**, [create a token](https://docs.gitlab.com/user/profile/personal_access_tokens/) with **read_repository** scope only. -- **Bitbucket**: In your [Atlassian account settings](https://id.atlassian.com/manage-profile/security) under **Security**, click **Create API token with scopes**. Select Bitbucket and grant `read:repository:bitbucket` only. +- **Bitbucket**: In your Bitbucket repository, go to **Repository settings** > **Security** > **Access tokens** and click **Create access token**. Under **Scopes**, select: + + - **Repositories**: **Read** + - **Pull requests**: **Read** + + Leave Write and all other scope groups unchecked. Click **Create** and copy the token immediately. + + The created token must have these scopes (visible in the **View access token** dialog after creation): + + - `repository` + - `pullrequest` ### 7. Connect your production Metabase to your repository diff --git a/docs/permissions/collections.md b/docs/permissions/collections.md index 0aedb05e8925..8c9f92328cde 100644 --- a/docs/permissions/collections.md +++ b/docs/permissions/collections.md @@ -36,7 +36,7 @@ The group can view, edit, move, delete, and pin items saved in this collection, ### View access -The group can see all the questions, dashboards, and models in the collection, as well as [events and timelines](../exploration-and-organization/events-and-timelines.md). +The group can see all the questions, dashboards, and models in the collection, as well as [events and timelines](../exploration-and-organization/events-and-timelines.md). Note: Curate access includes View access. ### No access @@ -70,7 +70,15 @@ Just like with data access permissions, collection permissions are _additive_, m ## Permissions and sub-collections -A group can be given access to a collection located somewhere within one or more sub-collections _without_ having to have access to every collection "above" it. For example, if a group had access to the "Super Secret Collection" that's saved several layers deep within a "Marketing" collection that the group lacks access to, the "Super Secret Collection" would show up at the top-most level that the group _does_ have access to. +- Changing access to a collection doesn't automatically change access to _existing_ subcollections, but all _new_ subcollections will inherit the access level. + + For example, let's say you have a `Campaigns` collection with a `2025 reports` subcollection, and you change the "Data team" group's access to `Campaigns` from "View" to "Curate". Then by default, Data team will get Curate access to `Campaigns` but will retain only "View" access to `2025 reports`. However, if after these permissions are configured, someone adds a new subcollection `2026 reports`, then Data team will get Curate access to "2026 reports" because new subcollections inherit permissions from the parent collection. + +- To change access for existing subcollections as well, toggle **Also change sub-collections** when changing collection access. + +- A group can be given access to a collection located somewhere within one or more sub-collections _without_ having to have access to every collection "above" it. + + For example, if a group had access to the "Super Secret Collection" that's saved several layers deep within a "Marketing" collection that the group lacks access to, the "Super Secret Collection" would show up at the top-most level that the group _does_ have access to. ## Deleting collections diff --git a/docs/questions/images/bubble-chart-colored-series-example.png b/docs/questions/images/bubble-chart-colored-series-example.png new file mode 100644 index 000000000000..59a06484d272 Binary files /dev/null and b/docs/questions/images/bubble-chart-colored-series-example.png differ diff --git a/docs/questions/images/bubble-chart-example.png b/docs/questions/images/bubble-chart-example.png new file mode 100644 index 000000000000..51bbcf4975e2 Binary files /dev/null and b/docs/questions/images/bubble-chart-example.png differ diff --git a/docs/questions/images/scatterplot-example.png b/docs/questions/images/scatterplot-example.png new file mode 100644 index 000000000000..34bd275ea0a5 Binary files /dev/null and b/docs/questions/images/scatterplot-example.png differ diff --git a/docs/questions/visualizations/scatterplot-or-bubble-chart.md b/docs/questions/visualizations/scatterplot-or-bubble-chart.md index d968a9148553..a372dd9a5b89 100644 --- a/docs/questions/visualizations/scatterplot-or-bubble-chart.md +++ b/docs/questions/visualizations/scatterplot-or-bubble-chart.md @@ -6,10 +6,151 @@ redirect_from: # Scatterplots and bubble charts -**Scatterplots** are useful for visualizing the correlation between two variables, like comparing the age of your users vs. how many dollars they've spent on your products. To use a scatterplot, you'll need to ask a question that results in two numeric columns, like `Count of Orders grouped by Customer Age`. Alternatively, you can use a table and select the two numeric fields you want to use in the chart options. +Scatterplots and bubble charts visualize the relationship between values. -If you have a third numeric field, you can also create a **bubble chart**. Select the Scatter visualization, then open up the chart settings and select a field in the **bubble size** dropdown. This field will be used to determine the size of each bubble on your chart. For example, you could use a field that contains the total dollar amount for each x-y pair — i.e. larger bubbles for larger total dollar amounts spent on orders. - -Scatterplots and bubble charts also have similar chart options as line, bar, and area charts, including the option to display trend or goal lines, or stack series. +Scatterplots show two numeric values per data point, while bubble charts show three by using the size of each dot to represent a third value. In Metabase, both chart types use the same **Scatter** visualization type. Adding a bubble size column turns a scatterplot into a bubble chart. ![Scatter](../images/scatter.png) + +## How to create a scatterplot or bubble chart + +You can create a scatterplot or bubble chart from a few different data shapes, depending on whether you want to add bubble sizing or colored series. + +### Create a scatterplot + +1. Create a question that returns two numeric columns. For example, summarize `Count of orders` and `Average of total` grouped by `Product ID`: + + | Product ID | Count of orders | Average of total | + | ---------- | --------------- | ---------------- | + | 1 | 93 | 42.48 | + | 2 | 98 | 104.48 | + | 3 | 77 | 52.68 | + | 4 | 89 | 108.95 | + | 5 | 97 | 117.08 | + + You can also use one numeric column grouped by a category or time dimension. For example, summarize `Average of total` grouped by `Product Category`: + + | Product Category | Average of total | + | ---------------- | ---------------- | + | Gizmo | 84.12 | + | Doohickey | 76.95 | + | Gadget | 92.40 | + | Widget | 88.27 | + +2. In the visualization picker, select **Scatter**. + +Each row in the result becomes a single dot on the chart. One column maps to the x-axis and the other to the y-axis. + +![Scatterplot example](../images/scatterplot-example.png) + +### Create a bubble chart + +1. Create a question that returns three numeric columns. For example, summarize `Count of orders`, `Average of total`, and `Sum of total` grouped by `Product ID`: + + | Product ID | Count of orders | Average of total | Sum of total | + | ---------- | --------------- | ---------------- | ------------ | + | 1 | 93 | 42.48 | 3,950.53 | + | 2 | 98 | 104.48 | 10,238.92 | + | 3 | 77 | 52.68 | 4,056.36 | + | 4 | 89 | 108.95 | 9,696.46 | + | 5 | 97 | 117.08 | 11,356.66 | + + You can also use two numeric columns grouped by a category or time dimension. For example, summarize `Average of total` and `Sum of total` grouped by `Product Category`: + + | Product Category | Average of total | Sum of total | + | ---------------- | ---------------- | ------------ | + | Gizmo | 84.12 | 425,000 | + | Doohickey | 76.95 | 312,400 | + | Gadget | 92.40 | 587,200 | + | Widget | 88.27 | 401,800 | + + You can also use one numeric column grouped by two category or time dimensions. For example, summarize `Average of total` grouped by `Product ID` and `Product Category`: + + | Product ID | Product Category | Average of total | + | ---------- | ---------------- | ---------------- | + | 1 | Gizmo | 42.48 | + | 2 | Doohickey | 104.48 | + | 3 | Doohickey | 52.68 | + | 4 | Doohickey | 108.95 | + | 5 | Gadget | 117.08 | + +2. In the visualization picker, select **Scatter**. +3. In chart settings, select a numeric column from the **Bubble size** menu. + +Each row in the result becomes a single dot on the chart. One column maps to the x-axis, one to the y-axis, and the bubble size column determines the size of the dot. + +![Bubble chart example](../images/bubble-chart-example.png) + +### Add a category for colored series + +1. Create a question with the data shape for a scatterplot or bubble chart. Include a category column for the colored series. For example, summarize `Count of orders` and `Average of total` grouped by `Product ID` and `Product Category`: + + | Product ID | Product Category | Count of orders | Average of total | + | ---------- | ---------------- | --------------- | ---------------- | + | 1 | Gizmo | 93 | 42.48 | + | 2 | Doohickey | 98 | 104.48 | + | 3 | Doohickey | 77 | 52.68 | + | 4 | Doohickey | 89 | 108.95 | + | 5 | Gadget | 97 | 117.08 | + +2. In the visualization picker, select **Scatter**. + +Each category becomes its own colored series. The example chart shows the Gizmo, Doohickey, Gadget, and Widget series. + +![Bubble chart colored series example](../images/bubble-chart-colored-series-example.png) + +## Scatterplot and bubble chart settings + +To open chart settings, click the **Gear** icon in the bottom left of the visualization. + +### Data + +In chart settings, click the **Data** tab to configure which columns appear on the chart. + +- **X-axis:** The column(s) to plot on the x-axis +- **Y-axis:** The column(s) to plot on the y-axis +- **Bubble size:** The column that determines the size of each dot (leave empty for a standard scatterplot) + +### Display + +In chart settings, click the **Display** tab to edit how the chart looks. + +To add a goal line, enable the **Goal line** toggle. Use the **Goal value** and **Goal label** fields to set the value and label. + +> You can't set [alerts](../alerts.md) on goal lines in scatterplots or bubble charts. + +To stack series on top of each other, enable the **Stack series** toggle. + +To show extra information when hovering over a dot, add columns to **Additional tooltip columns**. + +### Axes + +In chart settings, click the **Axes** tab to edit the chart axes. + +#### X-axis + +Use the following options for the x-axis: + +- **Show label:** Enable the toggle to display the x-axis label. +- **Label:** Name the x-axis label. +- **Show lines and tick marks:** Select how to display the x-axis line, tick marks, and labels. Choose between **Hide**, **Show**, **Compact**, **Rotate 45°**, and **Rotate 90°**. +- **Scale:** Select how values are spaced along the x-axis. Choose between **Linear**, **Power**, **Log**, **Histogram**, and **Ordinal**. + +#### Y-axis + +Use the following options for the y-axis: + +- **Show label:** Enable the toggle to display the y-axis label. +- **Label:** Name the y-axis label. +- **Split y-axis when necessary:** When enabled, Metabase displays separate y-axes for series with very different value ranges. +- **Auto y-axis range:** When enabled, Metabase sets the y-axis range automatically based on your data. When disabled, use the **Min** and **Max** fields to set custom values. +- **Unpin from zero:** When enabled, the y-axis can start at a value other than zero. +- **Scale:** Select how values are spaced along the y-axis. Choose between **Linear**, **Power**, and **Log**. +- **Show lines and tick marks:** Select how to display the y-axis line, tick marks, and labels. Choose between **Hide**, **Show**, **Compact**, **Rotate 45°**, and **Rotate 90°**. +- **Number of tick marks:** Set how many tick marks appear on the y-axis. Defaults to **auto**. + +## Limitations and alternatives + +- If you only have one numeric value to plot, or if you want to show how a value changes over time, consider a [bar chart, histogram, or line chart](./line-bar-and-area-charts.md) instead. +- Scatterplots can become hard to read with very large datasets because of overlapping dots. For large datasets, consider aggregating your data, or using a [heat map](./pivot-table.md#using-pivot-tables-as-heatmaps) or [box plot](./box-plot.md) instead. +- You can't set [alerts](../alerts.md) on goal lines in scatterplots or bubble charts. \ No newline at end of file diff --git a/e2e/support/helpers/e2e-filter-helpers.js b/e2e/support/helpers/e2e-filter-helpers.js index b719ff605f42..4162fa31a254 100644 --- a/e2e/support/helpers/e2e-filter-helpers.js +++ b/e2e/support/helpers/e2e-filter-helpers.js @@ -10,7 +10,7 @@ export function setSearchBoxFilterType() { cy.findByText("Search box").click(); } -export function setFilterQuestionSource({ question, field }) { +export function setFilterQuestionSource({ question, field, labelField }) { cy.findByText("Edit").click(); modal().within(() => { @@ -28,6 +28,18 @@ export function setFilterQuestionSource({ question, field }) { cy.findByText(field).click(); }); + if (labelField) { + cy.log("the label selector defaults to None until a column is chosen"); + modal().within(() => { + cy.findByText("Column to supply the labels").should("be.visible"); + cy.findByText("None").click(); + }); + + popover().within(() => { + cy.findByText(labelField).click(); + }); + } + modal().within(() => { cy.button("Done").click(); }); diff --git a/e2e/test-component/scenarios/embedding-sdk/internal-navigation.cy.spec.tsx b/e2e/test-component/scenarios/embedding-sdk/internal-navigation.cy.spec.tsx index 974365d8b2e1..672c34908627 100644 --- a/e2e/test-component/scenarios/embedding-sdk/internal-navigation.cy.spec.tsx +++ b/e2e/test-component/scenarios/embedding-sdk/internal-navigation.cy.spec.tsx @@ -87,7 +87,7 @@ describe("scenarios > embedding-sdk > internal-navigation", () => { row: 0, col: 0, size_x: 24, - size_y: 8, + size_y: 30, parameter_mappings: [ { parameter_id: DASHBOARD_B_FILTER.id, @@ -623,6 +623,47 @@ describe("scenarios > embedding-sdk > internal-navigation", () => { cy.findByText(/Back to/).should("not.exist"); }); }); + + it("should keep filters sticky on the navigated dashboard (EMB-1746)", () => { + cy.get("@dashboardAId").then((dashboardAId) => { + mountSdkContent( + , + ); + }); + + cy.wait("@getDashboard"); + cy.wait("@dashcardQuery"); + + getSdkRoot().within(() => { + cy.findByText("Dashboard A").should("be.visible"); + + cy.log("Navigate to Dashboard B via custom click behavior"); + H.getDashboardCard().findAllByText("Go to Dashboard B").first().click(); + cy.wait("@getDashboard"); + cy.findByText("Dashboard B").should("be.visible"); + H.filterWidget().should("be.visible"); + + // Pre-fix: nested wrapper dropped user height, so the scroll context + // was gone and the filter row scrolled off with the content. + cy.log("Scroll dashboard; filter row must stay pinned at wrapper top"); + cy.findByTestId("sdk-dashboard-styled-wrapper").scrollTo("bottom"); + + cy.findByTestId("sdk-dashboard-styled-wrapper").then(($wrapper) => { + const wrapperTop = $wrapper[0].getBoundingClientRect().top; + + H.filterWidget() + .should("be.visible") + .and(($filter) => { + const filterTop = $filter[0].getBoundingClientRect().top; + expect(filterTop).to.be.closeTo(wrapperTop, 20); + }); + }); + }); + }); }); describe("same-dashboard navigation (EMB-1714)", () => { diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filters-number-source.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-number-source.cy.spec.js index 3cede8f8e7dc..45a6ee28ffa3 100644 --- a/e2e/test/scenarios/dashboard-filters/dashboard-filters-number-source.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-number-source.cy.spec.js @@ -101,7 +101,7 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => { mapFilterToQuestion(); H.sidebar().findByText("Search box").click(); H.setFilterListSource({ - values: [[10, "Ten"], [20, "Twenty"], 30], + values: [["10", "Ten"], ["20", "Twenty"], "30"], }); H.saveDashboard(); @@ -265,7 +265,7 @@ const getListDashboard = (values_query_type) => { values_source_type: "static-list", values_query_type, values_source_config: { - values: [[10, "Ten"], [20, "Twenty"], 30], + values: [["10", "Ten"], ["20", "Twenty"], "30"], }, }); }; diff --git a/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js b/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js index c530d5466270..eba094a92d78 100644 --- a/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js +++ b/e2e/test/scenarios/dashboard-filters/dashboard-filters-source.cy.spec.js @@ -118,6 +118,123 @@ describe("scenarios > dashboard > filters", { tags: "@slow" }, () => { }); }); + describe("question source with custom labels", () => { + const stringLabelSource = { + name: "String label source", + native: { + query: + "SELECT DISTINCT CATEGORY, CONCAT(CATEGORY, ' Label') AS LABEL " + + "FROM PRODUCTS WHERE CATEGORY != 'Doohickey'", + }, + }; + + const numberLabelSource = { + name: "Number label source", + native: { + query: "SELECT ID, TITLE FROM PRODUCTS ORDER BY ID ASC LIMIT 5", + }, + }; + + it("should remap a string value to a string label (string/string)", () => { + H.createNativeQuestion(stringLabelSource, { wrapId: true }); + H.createQuestionAndDashboard({ + questionDetails: targetQuestion, + }).then(({ body: { dashboard_id } }) => { + H.visitDashboard(dashboard_id); + }); + + H.editDashboard(); + H.setFilter("Text or Category", "Is"); + mapFilterToQuestion(); + H.setFilterQuestionSource({ + question: "String label source", + field: "CATEGORY", + labelField: "LABEL", + }); + H.saveDashboard(); + + cy.log("the dropdown shows the labels instead of the raw values"); + H.filterWidget().click(); + H.popover().within(() => { + cy.findByText("Gadget Label").should("be.visible"); + cy.findByText("Gizmo Label").should("be.visible"); + cy.findByText("Gizmo").should("not.exist"); + cy.findByText("Gizmo Label").click(); + cy.button("Add filter").click(); + }); + + cy.log("the selected value is shown remapped to its label"); + H.filterWidget().findByText("Gizmo Label").should("be.visible"); + }); + + it("should remap a number value to a string label (number/string)", () => { + H.createNativeQuestion(numberLabelSource, { wrapId: true }); + H.createQuestionAndDashboard({ + questionDetails: targetQuestion, + }).then(({ body: { dashboard_id } }) => { + H.visitDashboard(dashboard_id); + }); + + H.editDashboard(); + H.setFilter("Number", "Equal to", "Number"); + + mapFilterToQuestion("Rating"); + H.setFilterQuestionSource({ + question: "Number label source", + field: "ID", + labelField: "TITLE", + }); + H.saveDashboard(); + + cy.log( + "the dropdown shows the product titles instead of the numeric ids", + ); + H.filterWidget().click(); + H.popover().within(() => { + cy.findByText("Rustic Paper Wallet").should("be.visible"); + cy.findByText("Rustic Paper Wallet").click(); + cy.button("Add filter").click(); + }); + + cy.log("the selected value is shown remapped to its label"); + H.filterWidget().findByText("Rustic Paper Wallet").should("be.visible"); + }); + + it("should remap an id value to a string label (id/string)", () => { + H.createNativeQuestion(numberLabelSource, { wrapId: true }); + H.createQuestionAndDashboard({ + questionDetails: targetQuestion, + }).then(({ body: { dashboard_id } }) => { + H.visitDashboard(dashboard_id); + }); + + H.editDashboard(); + H.setFilter("ID"); + + mapFilterToQuestion("ID"); + H.setFilterQuestionSource({ + question: "Number label source", + field: "ID", + labelField: "TITLE", + }); + H.saveDashboard(); + + cy.log( + "the dropdown shows the product titles instead of the numeric ids", + ); + H.filterWidget().click(); + H.popover().within(() => { + cy.findByText("Rustic Paper Wallet").should("be.visible"); + cy.findByText("Rustic Paper Wallet").click(); + cy.button("Add filter").click(); + }); + + cy.log("the selected value and the remapped label are shown"); + H.filterWidget().findByText("- 1").should("be.visible"); + H.filterWidget().findByText("Rustic Paper Wallet").should("be.visible"); + }); + }); + describe("native question source", () => { it("should be able to use a native question source", () => { H.createNativeQuestion(nativeSourceQuestion, { wrapId: true }); diff --git a/e2e/test/scenarios/data-studio/transforms/transforms.cy.spec.ts b/e2e/test/scenarios/data-studio/transforms/transforms.cy.spec.ts index 8246581139db..0c66d1815a21 100644 --- a/e2e/test/scenarios/data-studio/transforms/transforms.cy.spec.ts +++ b/e2e/test/scenarios/data-studio/transforms/transforms.cy.spec.ts @@ -32,7 +32,7 @@ const TARGET_SCHEMA = "Schema A"; const TARGET_SCHEMA_2 = "Schema B"; const CUSTOM_SCHEMA = "custom_schema"; -describe("scenarios > admin > transforms", () => { +describe("scenarios > admin > transforms", { tags: ["@external"] }, () => { beforeEach(() => { H.restore("postgres-writable"); H.resetTestTable({ type: "postgres", table: "many_schemas" }); @@ -3764,26 +3764,15 @@ describe("scenarios > admin > transforms", () => { H.expectNoBadSnowplowEvents(); }); - it("should not pick the only database when it is disabled in SQL editor", () => { - cy.log("create a new transform"); - visitTransformListPage(); - cy.button("Create a transform").click(); - H.popover().findByText("SQL query").click(); - - cy.findByTestId("gui-builder-data") - .findByText("Select a database") - .should("be.visible"); - }); - - it("should not pick the only database when it is disabled in Python editor", () => { + it("should show a message when no supported databases are available", () => { cy.log("create a new transform"); visitTransformListPage(); - cy.button("Create a transform").click(); - H.popover().findByText("Python script").click(); - - cy.findByTestId("python-transform-top-bar") - .findByText("Select a database") - .should("be.visible"); + cy.findByRole("heading", { + name: "To use transforms, you need a writable database connection", + }).should("exist"); + cy.findByRole("link", { name: "View your database connections" }).should( + "exist", + ); }); }); @@ -4159,13 +4148,13 @@ function checkSortingOrder(transformNames: string[]) { describe("scenarios > data studio > transforms > permissions > oss", () => { beforeEach(() => { - H.restore(); + H.restore("postgres-writable"); cy.signInAsAdmin(); }); it( "should be able to enable transforms in OSS without upsell gem icon", - { tags: "@OSS" }, + { tags: ["@OSS", "@external"] }, () => { cy.log("ensure that transform permissions are not shown"); cy.visit(`/admin/permissions/data/group/${ALL_USERS_GROUP_ID}`); @@ -4224,62 +4213,70 @@ describe("scenarios > data studio > transforms > permissions > oss", () => { ); }); -describe("scenarios > data studio > transforms > permissions > pro-self-hosted", () => { - beforeEach(() => { - H.restore(); - cy.signInAsAdmin(); - }); +describe( + "scenarios > data studio > transforms > permissions > pro-self-hosted", + { tags: ["@external"] }, + () => { + beforeEach(() => { + H.restore("postgres-writable"); + cy.signInAsAdmin(); + }); - it("should have transforms available in self-hosted pro without upsell gem icon", () => { - H.activateToken("pro-self-hosted").then(() => { - cy.log("ensure that transform permissions are not shown"); - cy.visit(`/admin/permissions/data/group/${ALL_USERS_GROUP_ID}`); + it("should have transforms available in self-hosted pro without upsell gem icon", () => { + H.activateToken("pro-self-hosted").then(() => { + cy.log("ensure that transform permissions are not shown"); + cy.visit(`/admin/permissions/data/group/${ALL_USERS_GROUP_ID}`); - //Check that a known header is present - cy.findByRole("columnheader", { name: "Database name" }).should( - "be.visible", - ); - //Ensure transform permissions are not displayed - cy.findByRole("columnheader", { name: /Transforms/ }).should("not.exist"); + //Check that a known header is present + cy.findByRole("columnheader", { name: "Database name" }).should( + "be.visible", + ); + //Ensure transform permissions are not displayed + cy.findByRole("columnheader", { name: /Transforms/ }).should( + "not.exist", + ); - cy.log("Visit data studio page"); - cy.visit("/data-studio"); - H.DataStudio.nav().should("be.visible"); + cy.log("Visit data studio page"); + cy.visit("/data-studio"); + H.DataStudio.nav().should("be.visible"); - cy.log("Verify Transforms menu item is visible"); - H.DataStudio.nav().findByText("Transforms").should("be.visible"); + cy.log("Verify Transforms menu item is visible"); + H.DataStudio.nav().findByText("Transforms").should("be.visible"); - cy.log("Verify no upsell gem icon is displayed in Transforms menu item"); - H.DataStudio.nav() - .findByText("Transforms") - .closest("a") - .within(() => { - cy.findByTestId("upsell-gem").should("not.exist"); - }); + cy.log( + "Verify no upsell gem icon is displayed in Transforms menu item", + ); + H.DataStudio.nav() + .findByText("Transforms") + .closest("a") + .within(() => { + cy.findByTestId("upsell-gem").should("not.exist"); + }); - cy.log("Verify transforms page is accessible"); - H.DataStudio.nav().findByText("Transforms").click(); - H.DataStudio.Transforms.enableTransformPage() - .findByRole("button", { name: "Enable transforms" }) - .click(); - H.DataStudio.Transforms.list().should("be.visible"); + cy.log("Verify transforms page is accessible"); + H.DataStudio.nav().findByText("Transforms").click(); + H.DataStudio.Transforms.enableTransformPage() + .findByRole("button", { name: "Enable transforms" }) + .click(); + H.DataStudio.Transforms.list().should("be.visible"); - cy.log("Verify can create transforms in pro-self-hosted"); - cy.button("Create a transform").should("be.visible"); + cy.log("Verify can create transforms in pro-self-hosted"); + cy.button("Create a transform").should("be.visible"); - cy.log("transform permissions should now be visible"); - H.goToAdmin(); - H.appBar().findByRole("link", { name: "Permissions" }).click(); - cy.findByRole("menuitem", { name: "All Users" }).click(); + cy.log("transform permissions should now be visible"); + H.goToAdmin(); + H.appBar().findByRole("link", { name: "Permissions" }).click(); + cy.findByRole("menuitem", { name: "All Users" }).click(); - //Check that a known header is present - cy.findByRole("columnheader", { name: "Database name" }).should( - "be.visible", - ); - //Ensure transform permissions are displayed - cy.findByRole("columnheader", { name: /Transforms/ }) - .scrollIntoView() - .should("be.visible"); + //Check that a known header is present + cy.findByRole("columnheader", { name: "Database name" }).should( + "be.visible", + ); + //Ensure transform permissions are displayed + cy.findByRole("columnheader", { name: /Transforms/ }) + .scrollIntoView() + .should("be.visible"); + }); }); - }); -}); + }, +); diff --git a/e2e/test/scenarios/embedding/sdk-iframe-embedding/view-and-curate-content.cy.spec.ts b/e2e/test/scenarios/embedding/sdk-iframe-embedding/view-and-curate-content.cy.spec.ts index 8ba4c73389e1..4a4dc54dec2e 100644 --- a/e2e/test/scenarios/embedding/sdk-iframe-embedding/view-and-curate-content.cy.spec.ts +++ b/e2e/test/scenarios/embedding/sdk-iframe-embedding/view-and-curate-content.cy.spec.ts @@ -174,7 +174,7 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content .should("be.visible"); }); - it("should show Save button and save modal without entity picker when creating a new question", () => { + it("should show Save button and save modal with entity picker preselecting the current collection when creating a new question (EMB-1609)", () => { setupEmbed( '', ); @@ -190,11 +190,14 @@ describe("scenarios > embedding > sdk iframe embedding > view and curate content .should("be.visible") .click(); - cy.log("should show save modal without entity picker"); + cy.log( + "save modal should show the collection picker pre-selected to the current collection", + ); H.getSimpleEmbedIframeContent().within(() => { cy.findByRole("dialog").within(() => { cy.findByText("Save new question").should("be.visible"); - cy.findByText("Where do you want to save this?").should("not.exist"); + cy.findByText("Where do you want to save this?").should("be.visible"); + cy.findByText("Our analytics").should("be.visible"); }); }); }); diff --git a/e2e/test/scenarios/metabot/usage-auditing.cy.spec.ts b/e2e/test/scenarios/metabot/usage-auditing.cy.spec.ts index 0b4f0a38bade..331c87e1d84f 100644 --- a/e2e/test/scenarios/metabot/usage-auditing.cy.spec.ts +++ b/e2e/test/scenarios/metabot/usage-auditing.cy.spec.ts @@ -1023,6 +1023,30 @@ describe("scenarios > metabot > usage auditing", () => { }); }); + it("sorts the conversations table by each sortable column", () => { + const sortableColumns: Array<{ headerLabel: RegExp; sortBy: string }> = [ + { headerLabel: /^User/, sortBy: "user" }, + { headerLabel: /^Profile/, sortBy: "profile_id" }, + { headerLabel: /^Date/, sortBy: "created_at" }, + { headerLabel: /^Messages/, sortBy: "message_count" }, + { headerLabel: /^Tokens/, sortBy: "total_tokens" }, + { headerLabel: /^IP/, sortBy: "ip_address" }, + ]; + + visitConversationsPage(); + + for (const { headerLabel, sortBy } of sortableColumns) { + H.main() + .findByRole("table") + .findByRole("button", { name: headerLabel }) + .click(); + + cy.wait("@conversations") + .its("request.url") + .should("include", `sort_by=${sortBy}`); + } + }); + it("shows message usage stats charts", () => { visitUsageStatsPage(); H.main().findByRole("tab", { name: "Messages" }).realClick(); diff --git a/enterprise/backend/src/metabase_enterprise/action_v2/coerce.clj b/enterprise/backend/src/metabase_enterprise/action_v2/coerce.clj index 9e7bfb05614c..0bf3b52493df 100644 --- a/enterprise/backend/src/metabase_enterprise/action_v2/coerce.clj +++ b/enterprise/backend/src/metabase_enterprise/action_v2/coerce.clj @@ -27,7 +27,6 @@ (.format ^DateTimeFormatter formatter t))] (defn- json-zdt->yyyymmddhhmmss [s] (-> s u.date/parse format)) - (defn- yyyymmddhhmmss->json-zdt [yyyymmddhhmmss] (-> (LocalDateTime/parse yyyymmddhhmmss formatter) diff --git a/enterprise/backend/src/metabase_enterprise/action_v2/execute_form.clj b/enterprise/backend/src/metabase_enterprise/action_v2/execute_form.clj index 374f50b0e178..7f9aa9ebfc6f 100644 --- a/enterprise/backend/src/metabase_enterprise/action_v2/execute_form.clj +++ b/enterprise/backend/src/metabase_enterprise/action_v2/execute_form.clj @@ -13,11 +13,9 @@ ;; TODO this is a clumsy workaround due to the api encoders not being run for some reason (mapv #(if (keyword? %) (strip-namespace-hack %) %) - [:enum {:encode/api name :decode/api #(keyword "input" %)} - :input/boolean :input/date :input/datetime @@ -129,7 +127,6 @@ ;; dashcard column context can hide parameters (if defined) :when (:enabled column-settings true) :let [required (or pk? (:database_required field false))]] - (u/remove-nils ;; TODO yet another comment about how field id would be a better key, due to case issues {:id (:name field) diff --git a/enterprise/backend/src/metabase_enterprise/action_v2/models/undo.clj b/enterprise/backend/src/metabase_enterprise/action_v2/models/undo.clj index 38473fd8a846..61b9fdea2a1f 100644 --- a/enterprise/backend/src/metabase_enterprise/action_v2/models/undo.clj +++ b/enterprise/backend/src/metabase_enterprise/action_v2/models/undo.clj @@ -117,14 +117,12 @@ ;; default value, can be override in values :undoable true} values))))) - ;; Delete snapshots that have been undone, as we keep a linear history and will no longer be able to "redo" them. (when-let [{:keys [batch_num]} (first (next-batch false user-id scope))] (t2/delete! :model/Undo :batch_num [:>= batch_num] :scope scope :undone true)) - ;; Pruning. Fairly naive implementation. Doesn't assume we were fully pruned before this update. (prune-from-batch! (max (batch-to-prune-from-for-rows retention-total-rows) (batch-to-prune-from retention-total-batches))) diff --git a/enterprise/backend/src/metabase_enterprise/advanced_config/file/api_keys.clj b/enterprise/backend/src/metabase_enterprise/advanced_config/file/api_keys.clj index 466439f77134..f8f5a86175cb 100644 --- a/enterprise/backend/src/metabase_enterprise/advanced_config/file/api_keys.clj +++ b/enterprise/backend/src/metabase_enterprise/advanced_config/file/api_keys.clj @@ -69,12 +69,10 @@ {:name name}))) prefix (api-key/prefix (u.secret/expose unhashed-key)) creator (get-admin-user-by-email creator)] - ;; Check if there's an existing API key with the same prefix (when (t2/exists? :model/ApiKey :key_prefix prefix) (throw (ex-info (format "API key with prefix '%s' already exists. Keys must have unique prefixes." prefix) {:name name :prefix prefix}))) - (log/info (u/format-color :green "Creating new API key %s" (pr-str name))) ;; Create a user for the API key (let [email (format "api-key-user-%s@api-key.invalid" (random-uuid)) diff --git a/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/block_permissions.clj b/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/block_permissions.clj index 1361c63e6d8c..9b9b96474f56 100644 --- a/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/block_permissions.clj +++ b/enterprise/backend/src/metabase_enterprise/advanced_permissions/models/permissions/block_permissions.clj @@ -34,5 +34,4 @@ (not= :blocked (perms/full-db-permission-for-user api/*current-user-id* :perms/view-data database-id)) (= #{:unrestricted} (set table-permissions)) (throw-block-permissions-exception)) - true)) diff --git a/enterprise/backend/src/metabase_enterprise/audit_app/analytics_dev.clj b/enterprise/backend/src/metabase_enterprise/audit_app/analytics_dev.clj index 29b1e4d9976f..a942c1132c3b 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_app/analytics_dev.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_app/analytics_dev.clj @@ -152,7 +152,6 @@ (let [yaml-data (yaml/parse-string (slurp file)) transformed (yaml->dev yaml-data user-email)] (spit target-file (yaml/generate-string transformed))))) - (log/info "YAML transformation complete") (.getPath temp-path))) @@ -170,7 +169,6 @@ (throw (ex-info "Analytics plugin directory not found after copy" {:path plugins-dir}))) temp-dir (copy-and-transform-yamls! plugins-dir user-email)] - (log/info "Ingesting YAMLs from" temp-dir) (try (let [report (serdes/with-cache (serialization/load-metabase! (serialization/ingest-yaml temp-dir) {:backfill? false}))] diff --git a/enterprise/backend/src/metabase_enterprise/audit_app/api/analytics_dev.clj b/enterprise/backend/src/metabase_enterprise/audit_app/api/analytics_dev.clj index 3a054f0b1ba5..03a69efbff3e 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_app/api/analytics_dev.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_app/api/analytics_dev.clj @@ -28,7 +28,6 @@ (let [collection (analytics-dev/find-analytics-collection)] (when-not collection (throw (ex-info "Analytics collection not found" {:status 404}))) - (let [temp-dir (Files/createTempDirectory "analytics-export" (make-array FileAttribute 0)) parent-dir (doto (.toFile temp-dir) .mkdirs) export-dir (doto (io/file parent-dir "instance_analytics") .mkdirs) @@ -39,17 +38,13 @@ (run! io/delete-file (reverse (file-seq parent-dir)))) (when (.exists dst) (io/delete-file dst)))] - (try (log/info "Exporting analytics collection" (:id collection)) (analytics-dev/export-analytics-content! (:id collection) user-email (.getPath export-dir)) - (log/info "Creating tarball" (.getPath dst)) (u.compress/tgz parent-dir dst) - {:archive (when (.exists dst) dst) :cleanup! cleanup!} - (catch Exception e (log/error e "Error during analytics export") (try (cleanup!) (catch Error _)) @@ -66,17 +61,13 @@ Requires superuser permissions." [] (api/check-superuser) - (api/check-400 (audit/analytics-dev-mode) "Analytics dev mode is not enabled") - (let [timer (u/start-timer) {:keys [archive error-message cleanup!]} (export-and-pack) duration (u/since-ms timer)] - (log/infof "Analytics export completed in %.0fms" duration) - (if archive {:status 200 :headers {"Content-Type" "application/gzip" diff --git a/enterprise/backend/src/metabase_enterprise/cache/task/refresh_cache_configs.clj b/enterprise/backend/src/metabase_enterprise/cache/task/refresh_cache_configs.clj index f59ab07e24a8..a090e4bc6726 100644 --- a/enterprise/backend/src/metabase_enterprise/cache/task/refresh_cache_configs.clj +++ b/enterprise/backend/src/metabase_enterprise/cache/task/refresh_cache_configs.clj @@ -122,9 +122,9 @@ (if parameterized? [:and [:= :qe.parameterized true] - ;; Only rerun a parameterized query if it's had a cache hit within the last caching window + ;; Only rerun a parameterized query if it's had a cache hit within the last caching window [:= :qe.cache_hit true] - ;; Don't factor the last cache refresh into whether we should rerun a parameterized query + ;; Don't factor the last cache refresh into whether we should rerun a parameterized query [:not= :qe.context (name :cache-refresh)]] [:= :qe.parameterized false])] :group-by [:q.query_hash :q.query :qc.query_hash :qe.card_id :qe.dashboard_id]}}))] diff --git a/enterprise/backend/src/metabase_enterprise/checker/provider.clj b/enterprise/backend/src/metabase_enterprise/checker/provider.clj index c28a94b6a505..5bd6c5d11d5b 100644 --- a/enterprise/backend/src/metabase_enterprise/checker/provider.clj +++ b/enterprise/backend/src/metabase_enterprise/checker/provider.clj @@ -278,12 +278,12 @@ ;;; =========================================================================== (deftype SourceMetadataProvider - ;; The store knows what entities exist, loads raw YAML data, assigns integer IDs, and caches. - ;; The provider adapts the store into the MetadataProvider interface that lib/query expects, - ;; converting raw YAML maps to lib metadata format along the way. - ;; - ;; `current-db` is an atom holding the database name (string) to return from `(database)`. - ;; Must be set via `set-database!` before each entity check and unset afterward. + ;; The store knows what entities exist, loads raw YAML data, assigns integer IDs, and caches. + ;; The provider adapts the store into the MetadataProvider interface that lib/query expects, + ;; converting raw YAML maps to lib metadata format along the way. + ;; + ;; `current-db` is an atom holding the database name (string) to return from `(database)`. + ;; Must be set via `set-database!` before each entity check and unset afterward. [store current-db] ISettableDatabase (set-database! [_this db-name] diff --git a/enterprise/backend/src/metabase_enterprise/cloud_add_ons/api.clj b/enterprise/backend/src/metabase_enterprise/cloud_add_ons/api.clj index 609938814689..f20c786d1648 100644 --- a/enterprise/backend/src/metabase_enterprise/cloud_add_ons/api.clj +++ b/enterprise/backend/src/metabase_enterprise/cloud_add_ons/api.clj @@ -27,8 +27,6 @@ (deferred-tru "Can only access Store API for Metabase Cloud instances.")) (def ^:private error-not-eligible (deferred-tru "Can only purchase add-ons for eligible subscriptions.")) -(def ^:private error-not-store-user - (deferred-tru "Only Metabase Store users can purchase add-ons.")) (def ^:private error-terms-not-accepted (deferred-tru "Need to accept terms of service.")) (def ^:private error-no-quantity @@ -38,8 +36,6 @@ {:status 400 :body error-not-hosted}) (def ^:private response-not-eligible {:status 400 :body error-not-eligible}) -(def ^:private response-not-store-user - {:status 403 :body error-not-store-user}) (def ^:private response-terms-not-accepted {:status 400 :body {:errors {:terms_of_service error-terms-not-accepted}}}) (def ^:private response-no-quantity @@ -149,10 +145,6 @@ (premium-features/enable-python-transforms?)) response-not-eligible - (not (contains? (set (map :email (:store-users (premium-features/token-status)))) - (:email @api/*current-user*))) - response-not-store-user - :else (try (let [add-on (cond-> {:product-type product-type} diff --git a/enterprise/backend/src/metabase_enterprise/content_translation/routes.clj b/enterprise/backend/src/metabase_enterprise/content_translation/routes.clj index 6bb4c434a25f..a2cb71d0ea68 100644 --- a/enterprise/backend/src/metabase_enterprise/content_translation/routes.clj +++ b/enterprise/backend/src/metabase_enterprise/content_translation/routes.clj @@ -76,7 +76,6 @@ [:map [:filename :string] [:tempfile (ms/InstanceOfClass java.io.File)]]]]]]] - (api/check-superuser) (let [file (get-in multipart-params ["file" :tempfile])] (when (> (get-in multipart-params ["file" :size]) max-content-translation-dictionary-size-bytes) diff --git a/enterprise/backend/src/metabase_enterprise/data_complexity_score/api.clj b/enterprise/backend/src/metabase_enterprise/data_complexity_score/api.clj index a891d1f2e2a2..9a2dae244fbd 100644 --- a/enterprise/backend/src/metabase_enterprise/data_complexity_score/api.clj +++ b/enterprise/backend/src/metabase_enterprise/data_complexity_score/api.clj @@ -15,29 +15,37 @@ (set! *warn-on-reflection* true) -(def ^:private SubScore - "Either a computed sub-score (`:measurement` + `:score`) or an uncomputed one (`:error`)." - [:or - [:map {:closed true} - [:measurement number?] - [:score nat-int?]] - [:map {:closed true} - [:measurement nil?] - [:score nil?] - [:error string?]]]) +(def ^:private Rating + "A rating band label drawn from `complexity-bands`." + [:enum "low" "medium" "high"]) -(def ^:private Catalog - "One catalog's total + per-component breakdown. - `:total` is nil when any sub-score couldn't be computed — failures cascade through aggregates." - [:map - [:total [:maybe nat-int?]] - [:components - [:map - [:entity_count SubScore] - [:name_collisions SubScore] - [:synonym_pairs SubScore] - [:field_count SubScore] - [:repeated_measures SubScore]]]]) +(def ^:private Failure + "Sub-score that couldn't be computed; carries only the failure message. + (Named `Failure` rather than `Error` to avoid shadowing `java.lang.Error`.)" + [:map {:closed true} + [:error string?]]) + +(def ^:private Leaf + "Computed leaf sub-score with a raw `:measurement` and its weighted `:score`." + [:map {:closed true} + [:measurement number?] + [:score nat-int?] + [:rating [:maybe Rating]] + [:rating_label [:maybe string?]]]) + +(def ^:private Grouping + "Internal node whose `:score` is the rolled-up sum of its `:components` children." + [:map {:closed true} + [:score [:maybe nat-int?]] + [:rating [:maybe Rating]] + [:rating_label [:maybe string?]] + [:components [:map-of :keyword [:ref ::node]]]]) + +(def ^:private Node + "Recursive score node: a [[Failure]], a [[Leaf]], or a [[Grouping]] whose children are themselves Nodes." + [:schema + {:registry {::node [:or Failure Leaf Grouping]}} + [:ref ::node]]) (def ^:private EmbeddingModelMeta "Identifies the embedding model backing the synonym calculations, so benchmark consumers can pin to it. @@ -50,12 +58,13 @@ (def ^:private ComplexityScoresResponse "Full response body for `GET /api/ee/data-complexity-score/complexity`." [:map - [:library Catalog] - [:universe Catalog] - [:metabot Catalog] + [:library Node] + [:universe Node] + [:metabot Node] [:meta [:map [:formula_version pos-int?] + [:format_version pos-int?] [:synonym_threshold number?] [:calculated_at {:optional true} some?] [:embedding_model {:optional true} EmbeddingModelMeta]]]]) @@ -87,7 +96,7 @@ :metabot-scope (metabot-scope/internal-metabot-scope))) stored (data-complexity-score/record-score! fingerprint "appdb" result)] (task.complexity-score/maybe-advance-last-fingerprint! fingerprint result) - (m.util/deep-snake-keys (or stored result))) + (m.util/deep-snake-keys (complexity/decorate-with-ratings (or stored result)))) (finally (.set api-scoring-running? false)))) @@ -103,6 +112,7 @@ (if force-recalculation? (force-recalculate-score!) (api/check-404 (some-> (data-complexity-score/latest-score (task.complexity-score/current-fingerprint)) + complexity/decorate-with-ratings m.util/deep-snake-keys) (tru "Data Complexity Score has not been computed yet. Recompute it to create the first snapshot.")))) diff --git a/enterprise/backend/src/metabase_enterprise/data_complexity_score/cli.clj b/enterprise/backend/src/metabase_enterprise/data_complexity_score/cli.clj index 741a59e2f415..afb44b438646 100644 --- a/enterprise/backend/src/metabase_enterprise/data_complexity_score/cli.clj +++ b/enterprise/backend/src/metabase_enterprise/data_complexity_score/cli.clj @@ -29,7 +29,6 @@ and the same bootstrap-dispatched [[entrypoint]] hook." (:require [clojure.java.io :as io] - [clojure.pprint :as pprint] [clojure.tools.cli :as cli] [metabase-enterprise.data-complexity-score.complexity :as complexity] [metabase-enterprise.data-complexity-score.metabot-scope :as metabot-scope] @@ -37,7 +36,8 @@ [metabase-enterprise.data-complexity-score.representation :as representation] [metabase-enterprise.data-complexity-score.synonym-source :as synonym-source] [metabase-enterprise.data-complexity-score.task.complexity-score :as task.complexity-score] - [metabase.app-db.core :as mdb])) + [metabase.app-db.core :as mdb] + [metabase.util.json :as json])) (set! *warn-on-reflection* true) @@ -84,7 +84,7 @@ :default unset :parse-fn #(case % "true" true "false" false ::invalid) :validate [#(not= % ::invalid) "--write-to-appdb must be 'true' or 'false'."]] - ["-o" "--output PATH" "Write EDN result to this file instead of stdout."] + ["-o" "--output PATH" "Write pretty JSON to a file instead of single-line JSON on stdout."] ;; Consumed by `metabase.core.bootstrap` when invoked as `java -jar metabase.jar --mode …`. Declared ;; here so tools.cli accepts it and `parse-opts` doesn't report it as an unknown flag. [nil "--mode MODE" "(JAR invocation only; handled by bootstrap before reaching this CLI.)"] @@ -96,17 +96,11 @@ " java -jar metabase.jar --mode complexity-score [options]\n\n" "Options:\n" summary)) -(defn- pretty [result] - (with-out-str - #_{:clj-kondo/ignore [:discouraged-var]} - (pprint/pprint result))) - (defn- write-result! [result output-path] (if output-path - (spit output-path (pretty result)) + (spit output-path (json/encode result {:pretty true})) (do - #_{:clj-kondo/ignore [:discouraged-var]} - (print (pretty result)) + (output! (json/encode result)) (flush)))) (defn- resolve-write? diff --git a/enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity.clj b/enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity.clj index 8bac77affbeb..92e0c8c69533 100644 --- a/enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity.clj +++ b/enterprise/backend/src/metabase_enterprise/data_complexity_score/complexity.clj @@ -18,11 +18,13 @@ (set! *warn-on-reflection* true) (def formula-version - "Bump when the scoring formula changes in a way that would break historical comparisons. + "Bump when the scoring formula changes in a way that would break historical score comparisons. + Weight changes, new/removed components, and rollup-affecting restructures count; pure-shape changes do not. + Tunables in the fingerprint (`embedding-model`, `synonym-threshold`, `text-variant`, etc) don't need a bump." + 1) - Changes to `embedding-model`, `synonym-threshold`, `text-variant`, etc don't need a bump. - These parameters are already captured in the fingerprint. - Formula versioning is reserved for cases where the actual formulas or components change." +(def format-version + "Bump when the response shape changes in a way that breaks consumer parsing, even when scores are equivalent." 1) (def weights @@ -35,15 +37,70 @@ :field 1 :repeated-measure 2}) -(def ^:private component->group - "Thematic parent per sub-component — drives the `.total` + `.` rollup in - emitted keys so operators can tell `size` from `ambiguity` without SQL. - Must cover every key produced by [[score-catalog]] or the missing ones emit as `nil.`." - {:entity-count :size - :field-count :size - :name-collisions :ambiguity - :synonym-pairs :ambiguity - :repeated-measures :ambiguity}) +(def complexity-bands + "Tree of rating bands mirroring [[score-catalog]]'s output. + Each node's `:bands` rates that node's `:score`; `:components` follows the same nesting as the scored catalog." + {:bands [{:rating "low" :label "Low complexity" :max 999} + {:rating "medium" :label "Medium complexity" :max 9999} + {:rating "high" :label "High complexity"}]}) + +(def ^:private nil-rating {:rating nil :rating-label nil}) + +(defn- ->band-lookup + "Bundle `bands` with a pre-built `:rating`→presentation `:lookup` for fast rating-by-score." + [bands] + {:bands bands + :lookup (u/for-map [{:keys [rating label]} bands] + [rating {:rating rating :rating-label label}])}) + +(defn- compile-bands + "Replace each node's `:bands` in the bands tree with a precomputed `:band-lookup`." + [bands] + (cond-> {} + (:bands bands) + (assoc :band-lookup (->band-lookup (:bands bands))) + + (:components bands) + (assoc :components (update-vals (:components bands) compile-bands)))) + +(def ^:private compiled-bands + (compile-bands complexity-bands)) + +(defn- rating-for-score + "Look up `score`'s rating in a preprocessed `band-lookup`, or `nil-rating` when nothing matches." + [{:keys [bands lookup]} score] + (or (when (some? score) + (some (fn [{:keys [rating max]}] + (when (or (nil? max) (<= score max)) + (lookup rating))) + bands)) + nil-rating)) + +(defn- decorate-with-ratings* + "Walk a catalog `node` and the matching `bands` subtree in parallel, merging rating fields onto every node. + Each node is rated against its own `:band-lookup`, or `nil-rating` when absent. + Error leaves carry only `:error` and are left untouched. Children recurse along `:components`." + [bands node] + (if (:error node) + node + (let [decorated (merge node (rating-for-score (:band-lookup bands) (:score node)))] + (cond-> decorated + (:components node) + (update :components + (fn [components] + (reduce-kv (fn [m k child] + (assoc m k (decorate-with-ratings* (get-in bands [:components k]) + child))) + {} + components))))))) + +;; TODO: also emit the rating onto Snowplow events so benchmark consumers can correlate the band +;; against the raw score without re-applying the bands. +(defn decorate-with-ratings + "Decorate each catalog with rating fields per `complexity-bands`." + [score] + (let [decorate (partial decorate-with-ratings* compiled-bands)] + (reduce #(u/update-if-exists %1 %2 decorate) score [:library :universe :metabot]))) (def synonym-similarity-threshold "Cosine-similarity cutoff for flagging two names as synonyms. @@ -291,10 +348,10 @@ :value-doesnt-matter)))) (defn- score-synonym-pairs - "Compute the synonym sub-score for `entities` using `embedder`. On embedder failure, returns nil - `:score`/`:measurement` plus an `:error` string so the failure cascades through aggregates - instead of being mistaken for a real zero. A nil `embedder` produces an empty lookup and scores - a real zero." + "Compute the synonym sub-score for `entities` using `embedder`. + On embedder failure returns an `{:error \"...\"}`-only leaf, so the failure cascades through aggregates + (their `:score` rolls up to nil) and consumers can branch on `:error` presence. + A nil `embedder` produces an empty lookup and scores a real zero." [entities embedder] (try (let [name->vec (or (and embedder (embedder entities)) {}) @@ -312,7 +369,7 @@ err (if (str/blank? msg) (or (some-> (class t) .getName) "synonym detection failed") msg)] - {:measurement nil :score nil :error err})))) + {:error err})))) (defn- nil-safe-sum "Sum `xs` (numbers and/or nils). Returns nil if any element is nil — used to cascade an @@ -321,16 +378,33 @@ (when (every? some? xs) (reduce + xs))) +(defn- score-tree-leaves + "Pure: build the leaves-only tree of sub-score maps for one catalog. + Each leaf is a `{:measurement :score [:error ]}` map; the surrounding shape + defines the `:size`/`:ambiguity` grouping that [[score-catalog]] then rolls up." + [entities embedder] + {:size {:entity-count (score-entity-count entities) + :field-count (score-field-count entities)} + :ambiguity {:name-collisions (score-name-collisions entities) + :synonym-pairs (score-synonym-pairs entities embedder) + :repeated-measures (score-repeated-measures entities)}}) + +(defn- rollup-node + "Recursively roll up a node's children into `{:score :components {...}}`. + Leaves — maps carrying `:score` (computed) or `:error` (uncomputed) — pass through unchanged. + `:score` cascades nil through aggregates so an uncomputed sub-score nils out everything it feeds." + [node] + (if (or (contains? node :score) (contains? node :error)) + node + (let [components (update-vals node rollup-node)] + {:score (nil-safe-sum (map (comp :score val) components)) + :components components}))) + (defn score-catalog - "Pure: compute the score breakdown for a catalog given its `entities` and an optional `embedder`." + "Pure: compute the score breakdown for a catalog given its `entities` and an optional `embedder`. + Returns `{:score :components {:size {...} :ambiguity {...}}}`; see [[score-tree-leaves]] for the leaf layout." [entities embedder] - (let [components {:entity-count (score-entity-count entities) - :name-collisions (score-name-collisions entities) - :synonym-pairs (score-synonym-pairs entities embedder) - :field-count (score-field-count entities) - :repeated-measures (score-repeated-measures entities)}] - {:total (nil-safe-sum (map (comp :score val) components)) - :components components})) + (rollup-node (score-tree-leaves entities embedder))) ;;; ----------------------------------- public API ------------------------------------ @@ -384,6 +458,23 @@ [event score] (cond-> event (some? score) (assoc :score score))) +(defn- node->events + "Walk one catalog and emit a Snowplow event per node. + Computed leaves use a `` key and carry `:measurement`; error leaves use the same `` key with `:error`. + Internal nodes use a `.total` key with the rolled-up `:score` (the root emits as `total`). + `:score` is omitted when nil; see [[with-score]]." + [base catalog path node] + (let [;; `:total` here is the Snowplow wire-format suffix for rollup nodes, not an in-memory field. + key (apply dotted-key (if (:components node) (conj path :total) path)) + event (cond-> (-> base + (assoc :catalog catalog :key key) + (with-score (:score node))) + (:measurement node) (assoc :measurement (:measurement node)) + (:error node) (assoc :error (truncate-error (:error node))))] + (cons event + (mapcat (fn [[k child]] (node->events base catalog (conj path k) child)) + (:components node))))) + (defn- emit-snowplow! "Submits Snowplow events for every score, every group aggregation, and the grand total. Returns true when they are all successfully delivered. @@ -393,27 +484,8 @@ :batch_id (str (random-uuid)) :formula_version (:formula-version meta) :parameters (parameters-map meta)} - events (for [[catalog result] [[:library library] [:universe universe] [:metabot metabot]] - event (concat (for [[component sub] (:components result)] - ;; leaf component - (cond-> (-> base - (assoc :catalog catalog - :key (dotted-key (component->group component) component) - :measurement (:measurement sub)) - (with-score (:score sub))) - (:error sub) (assoc :error (truncate-error (:error sub))))) - (for [[group entries] (group-by #(component->group (key %)) (:components result))] - ;; group total - (-> base - (assoc :catalog catalog - :key (dotted-key group :total)) - (with-score (nil-safe-sum (map (comp :score val) entries))))) - ;; grand total - [(-> base - (assoc :catalog catalog - :key (dotted-key :total)) - (with-score (:total result)))])] - event)] + events (mapcat (fn [[catalog result]] (node->events base catalog [] result)) + [[:library library] [:universe universe] [:metabot metabot]])] ;; No short-circuiting - even if they are failures, attempt the rest. (reduce (fn [all-ok? event] (and (analytics/track-event! :snowplow/data_complexity event) all-ok?)) @@ -443,6 +515,7 @@ universe-score (score-catalog metabot-entities embedder)) :meta (cond-> {:formula-version formula-version + :format-version format-version :synonym-threshold synonym-similarity-threshold :weights weights} embedding-model-meta (assoc :embedding-model embedding-model-meta) @@ -465,10 +538,11 @@ Returns a map of the shape: - {:library {:total n :components {...}} - :universe {:total n :components {...}} - :metabot {:total n :components {...}} + {:library {:score n :components {...}} + :universe {:score n :components {...}} + :metabot {:score n :components {...}} :meta {:formula-version 1 + :format-version 1 :synonym-threshold 0.80 :embedding-model {:provider ..., :model-name ..., :model-dimensions ...} :text-variant :names-split}} @@ -507,6 +581,7 @@ :universe universe-score :metabot metabot-score :meta (cond-> {:formula-version formula-version + :format-version format-version :synonym-threshold synonym-similarity-threshold} embedding-model-meta (assoc :embedding-model embedding-model-meta) text-variant (assoc :text-variant text-variant))}] diff --git a/enterprise/backend/src/metabase_enterprise/data_complexity_score/task/complexity_score.clj b/enterprise/backend/src/metabase_enterprise/data_complexity_score/task/complexity_score.clj index 6130af978bd8..f52f76b5a3af 100644 --- a/enterprise/backend/src/metabase_enterprise/data_complexity_score/task/complexity_score.clj +++ b/enterprise/backend/src/metabase_enterprise/data_complexity_score/task/complexity_score.clj @@ -23,19 +23,13 @@ (def ^:private trigger-key (triggers/key "metabase.task.data-complexity-score.trigger")) (defn current-fingerprint - "String capturing everything that changes the meaning of an emitted score. - - Mirrors the Snowplow `formula_version` + `parameters` fields. `weights` is included so re-tuning - forces a re-score without a `formula-version` bump; only structural scoring-algorithm changes - need that. - - The synonym-axis fragment comes from [[synonym-source/fingerprint-fragment]] so the fingerprint - reacts to the source toggle and the configured model — and on the search-index path also picks - up swaps in the active pgvector model so a re-index that swaps the model invalidates prior - scores." + "String capturing everything that changes the meaning or shape of an emitted score. + Includes `formula-version`, `format-version`, `weights`, `synonym-threshold`, and the synonym-axis fragment + from [[synonym-source/fingerprint-fragment]] (source toggles, configured embedder, pgvector index swaps)." [] (pr-str (into (sorted-map) (merge {:formula-version complexity/formula-version + :format-version complexity/format-version :synonym-threshold complexity/synonym-similarity-threshold :weights complexity/weights} (synonym-source/fingerprint-fragment))))) diff --git a/enterprise/backend/src/metabase_enterprise/dependencies/api.clj b/enterprise/backend/src/metabase_enterprise/dependencies/api.clj index 62956ba6b851..25490b859bf0 100644 --- a/enterprise/backend/src/metabase_enterprise/dependencies/api.clj +++ b/enterprise/backend/src/metabase_enterprise/dependencies/api.clj @@ -82,10 +82,10 @@ card (-> original (assoc :dataset-query (:dataset_query body) :type (:type body (:type original))) - ;; Remove the old `:result-metadata` from the card, it's likely wrong now. + ;; Remove the old `:result-metadata` from the card, it's likely wrong now. (dissoc :result-metadata) - ;; But if the request includes `:result_metadata`, use that. It may be from a native card - ;; that's been run before saving the card. + ;; But if the request includes `:result_metadata`, use that. It may be from a native card + ;; that's been run before saving the card. (cond-> #_card (:result_metadata body) (assoc :result-metadata (:result_metadata body)))) edits {:card [card]} diff --git a/enterprise/backend/src/metabase_enterprise/dependencies/core.clj b/enterprise/backend/src/metabase_enterprise/dependencies/core.clj index 5850a85737f8..1eb03df74480 100644 --- a/enterprise/backend/src/metabase_enterprise/dependencies/core.clj +++ b/enterprise/backend/src/metabase_enterprise/dependencies/core.clj @@ -102,7 +102,6 @@ (when (seq (:snippet deps)) ;; Copy any snippet deps to each database, since they span them all. (vswap! by-db update-vals #(assoc % :snippet (:snippet deps)))) - @by-db)) (mu/defn errors-from-proposed-edits :- [:map-of ::entity-type [:map-of :int [:set [:ref ::lib.schema.validate/error]]]] diff --git a/enterprise/backend/src/metabase_enterprise/dependencies/metadata_provider.clj b/enterprise/backend/src/metabase_enterprise/dependencies/metadata_provider.clj index ea44af6853b2..e01ae56e4464 100644 --- a/enterprise/backend/src/metabase_enterprise/dependencies/metadata_provider.clj +++ b/enterprise/backend/src/metabase_enterprise/dependencies/metadata_provider.clj @@ -35,7 +35,6 @@ (seq metadata-keys)) (let [overrides (if (seq id) (select-keys type-overrides id) - (into {} (keep #(when-let [x (deref %)] (when (search-name (:name x)) [(:name x) %]))) diff --git a/enterprise/backend/src/metabase_enterprise/dependencies/metadata_update.clj b/enterprise/backend/src/metabase_enterprise/dependencies/metadata_update.clj index 6a0434d00d51..697c935304b4 100644 --- a/enterprise/backend/src/metabase_enterprise/dependencies/metadata_update.clj +++ b/enterprise/backend/src/metabase_enterprise/dependencies/metadata_update.clj @@ -187,7 +187,6 @@ (if (= new-metadata old-metadata) ::graph/stop [node-id new-metadata]))))))] - (doseq [[card-id new-metadata] updates] (t2/update! :model/Card card-id {:result_metadata new-metadata})))) diff --git a/enterprise/backend/src/metabase_enterprise/email/api.clj b/enterprise/backend/src/metabase_enterprise/email/api.clj index cccbc4158f32..2f32b2caa6a9 100644 --- a/enterprise/backend/src/metabase_enterprise/email/api.clj +++ b/enterprise/backend/src/metabase_enterprise/email/api.clj @@ -40,7 +40,6 @@ [:email-smtp-security-override {:optional true} [:or string? nil?]] [:email-smtp-username-override {:optional true} [:or string? nil?]]]] (check-features) - ;; Validations match validation in settings, but pre-checking here to avoid attempting network checks for invalid settings. (when (and (:email-smtp-port-override settings) (not (#{465 587 2525} (:email-smtp-port-override settings)))) @@ -50,7 +49,6 @@ (not (#{:tls :ssl :starttls} (keyword (:email-smtp-security-override settings))))) (throw (ex-info (tru "Invalid email-smtp-security-override value") {:status-code 400}))) - (u/prog1 (email/check-and-update-settings settings mb-to-smtp-override-settings (channel.settings/email-smtp-password-override)) (when (nil? (:errors (:body <>))) (channel.settings/smtp-override-enabled! true)))) diff --git a/enterprise/backend/src/metabase_enterprise/gsheets/api.clj b/enterprise/backend/src/metabase_enterprise/gsheets/api.clj index 85db93730085..2a771634f1de 100644 --- a/enterprise/backend/src/metabase_enterprise/gsheets/api.clj +++ b/enterprise/backend/src/metabase_enterprise/gsheets/api.clj @@ -231,7 +231,6 @@ (when-not (some? attached-dwh) (snowplow/track-event! :snowplow/simple_event {:event "sheets_connected" :event_detail "fail - no dwh"}) (throw-error 400 (tru "No attached dwh found.") nil)) - (let [[status response] (hm-create-gdrive-conn! url) created-at (seconds-from-epoch-now) created-by-id api/*current-user-id*] @@ -401,6 +400,5 @@ ;; This is what the notify endpoint calls to do a sync on the attached dwh: #_{:clj-kondo/ignore [:metabase/modules]} (require '[metabase.sync.sync-metadata :as sync-metadata]) - (sync-metadata/sync-db-metadata! (t2/select-one :model/Database :is_attached_dwh true)))) diff --git a/enterprise/backend/src/metabase_enterprise/gsheets/settings.clj b/enterprise/backend/src/metabase_enterprise/gsheets/settings.clj index a39a1b0d0cb6..1940128b8a61 100644 --- a/enterprise/backend/src/metabase_enterprise/gsheets/settings.clj +++ b/enterprise/backend/src/metabase_enterprise/gsheets/settings.clj @@ -19,7 +19,6 @@ [:message ms/NonBlankString]] [:multi {:dispatch :status} ["not-connected" [:map]] - ["initializing" [:map [:url ms/NonBlankString] @@ -29,7 +28,6 @@ [:sync_started_at pos-int?] [:created_by_id pos-int?] [:db_id pos-int?]]] - ["syncing" [:map [:url ms/NonBlankString] @@ -39,7 +37,6 @@ [:sync_started_at pos-int?] [:created_by_id pos-int?] [:db_id pos-int?]]] - ["active" [:map [:url ms/NonBlankString] @@ -124,8 +121,8 @@ :type :json :getter (mu/fn :- :gsheets/setting [] (or - ;; This NEEDS to be up to date between instances on a cluster, so: - ;; we are going around the settings cache: + ;; This NEEDS to be up to date between instances on a cluster, so: + ;; we are going around the settings cache: (some-> (t2/select-one :model/Setting :key "gsheets") :value json/decode+kw migrate-gsheet-value) (u/prog1 gsheets.constants/not-connected (setting/set-value-of-type! :json :gsheets <>))))) diff --git a/enterprise/backend/src/metabase_enterprise/harbormaster/client.clj b/enterprise/backend/src/metabase_enterprise/harbormaster/client.clj index bab0b13f466e..7f0ef974cd59 100644 --- a/enterprise/backend/src/metabase_enterprise/harbormaster/client.clj +++ b/enterprise/backend/src/metabase_enterprise/harbormaster/client.clj @@ -232,4 +232,4 @@ ;; Call the :list-connections operation. (call :list-connections)) - ;; => () +;; => () diff --git a/enterprise/backend/src/metabase_enterprise/metabot/settings.clj b/enterprise/backend/src/metabase_enterprise/metabot/settings.clj index 6dd7213a472e..92deb0282593 100644 --- a/enterprise/backend/src/metabase_enterprise/metabot/settings.clj +++ b/enterprise/backend/src/metabase_enterprise/metabot/settings.clj @@ -2,8 +2,7 @@ "Enterprise-only metabot settings for usage limits." (:require [metabase.settings.core :as setting :refer [defsetting]] - [metabase.util.i18n :refer [deferred-tru]] - [metabase.util.log :as log])) + [metabase.util.i18n :refer [deferred-tru]])) (def ^:private valid-limit-units #{:tokens :messages}) @@ -58,47 +57,3 @@ :encryption :no :export? true :doc false) - -(def ^:private min-retention-days - "Minimum allowed value for `ai-usage-max-retention-days`." - 30) - -(def ^:private default-retention-days - "Default value for `ai-usage-max-retention-days` (~6 months)." - 180) - -(defn- log-minimum-value-warning - [env-var-value] - (log/warnf "MB_AI_USAGE_MAX_RETENTION_DAYS is set to %d; using the minimum value of %d instead." - env-var-value - min-retention-days)) - -(defn- -ai-usage-max-retention-days [] - (let [env-var-value (setting/get-value-of-type :integer :ai-usage-max-retention-days)] - (cond - (nil? env-var-value) - default-retention-days - - ;; Treat 0 as an alias for infinity - (zero? env-var-value) - ##Inf - - (< env-var-value min-retention-days) - (do - (log-minimum-value-warning env-var-value) - min-retention-days) - - :else - env-var-value))) - -(defsetting ai-usage-max-retention-days - (deferred-tru "Number of days to retain rows in the ai_usage_log table. Minimum value is 30; set to 0 to retain data indefinitely.") - :visibility :internal - :setter :none - :audit :never - :export? false - :getter #'-ai-usage-max-retention-days - :doc "Sets the maximum number of days Metabase preserves rows in the `ai_usage_log` table. - -Once a day, Metabase deletes rows older than this threshold. The minimum value is 30 days (Metabase will treat entered values of 1 to 29 the same as 30). -If set to 0, Metabase will keep all rows.") diff --git a/enterprise/backend/src/metabase_enterprise/metabot/task/ai_usage_trimmer.clj b/enterprise/backend/src/metabase_enterprise/metabot/task/ai_usage_trimmer.clj index 8fff23e108cb..47f839d529fc 100644 --- a/enterprise/backend/src/metabase_enterprise/metabot/task/ai_usage_trimmer.clj +++ b/enterprise/backend/src/metabase_enterprise/metabot/task/ai_usage_trimmer.clj @@ -5,7 +5,7 @@ [clojurewerkz.quartzite.schedule.cron :as cron] [clojurewerkz.quartzite.triggers :as triggers] [java-time.api :as t] - [metabase-enterprise.metabot.settings :as metabot.settings] + [metabase.metabot.settings :as metabot.settings] [metabase.task.core :as task] [metabase.util.log :as log] [toucan2.core :as t2]) @@ -20,7 +20,7 @@ (defn- trim-old-usage-data! [] (let [retention-days (metabot.settings/ai-usage-max-retention-days)] - (if (infinite? retention-days) + (if (nil? retention-days) (log/info "Skipping AI usage log cleanup; ai-usage-max-retention-days is 0 (infinite retention).") (do (log/infof "Trimming AI usage log rows older than %d days." (long retention-days)) @@ -42,6 +42,6 @@ (triggers/with-identity trimmer-trigger-key) (triggers/start-now) (triggers/with-schedule - ;; daily at 23:14:37 + ;; daily at 23:14:37 (cron/cron-schedule "37 14 23 * * ?")))] (task/schedule-task! job trigger))) diff --git a/enterprise/backend/src/metabase_enterprise/metabot/tools/transforms/write.clj b/enterprise/backend/src/metabase_enterprise/metabot/tools/transforms/write.clj index f94664e73f50..a39df49633a5 100644 --- a/enterprise/backend/src/metabase_enterprise/metabot/tools/transforms/write.clj +++ b/enterprise/backend/src/metabase_enterprise/metabot/tools/transforms/write.clj @@ -76,14 +76,11 @@ source_database (assoc-in [:source :source-database] source_database) source_tables (assoc-in [:source :source-tables] source_tables) true (assoc-in [:source :body] new-python))] - ;; Store in memory if we have an ID (when (and transform_id memory-atom) (swap! memory-atom assoc-in [:state :transforms (str transform_id)] suggested-transform)) - (log/debug "Python transform written" {:transform-id transform_id :python-length (count new-python)}) - {:structured-output {:transform suggested-transform :thinking thinking :message "Transform Python code updated successfully."} diff --git a/enterprise/backend/src/metabase_enterprise/metabot_analytics/api.clj b/enterprise/backend/src/metabase_enterprise/metabot_analytics/api.clj index 6ef759d6bb6f..a0254b082094 100644 --- a/enterprise/backend/src/metabase_enterprise/metabot_analytics/api.clj +++ b/enterprise/backend/src/metabase_enterprise/metabot_analytics/api.clj @@ -49,7 +49,7 @@ (def ^:private SortColumn "Allow-list of columns the list endpoint can sort by." - [:enum "created_at" "message_count" "total_tokens"]) + [:enum "created_at" "message_count" "total_tokens" "user" "profile_id" "ip_address"]) (def ^:private SortDirection [:enum "asc" "desc"]) diff --git a/enterprise/backend/src/metabase_enterprise/metabot_analytics/conversations.clj b/enterprise/backend/src/metabase_enterprise/metabot_analytics/conversations.clj index 36313d385172..208450c79ac8 100644 --- a/enterprise/backend/src/metabase_enterprise/metabot_analytics/conversations.clj +++ b/enterprise/backend/src/metabase_enterprise/metabot_analytics/conversations.clj @@ -71,11 +71,16 @@ (some-> user (select-keys [:id :email :first_name :last_name :tenant_id]))) (def ^:private sort-columns - "Allow-list of API sort keys → HoneySQL column refs, to keep user input out - of the `:order-by` clause." - {"created_at" :c.created_at - "message_count" :message_count - "total_tokens" :total_tokens}) + "Allow-list of API sort keys → vectors of HoneySQL ORDER BY expressions (sans + direction). A vector lets a single sort key emit multiple ORDER BY terms that + share the same direction (e.g. user sort orders by first_name then last_name)." + {"created_at" [:c.created_at] + "message_count" [:message_count] + "total_tokens" [:total_tokens] + "user" [[:lower [:min :u.first_name]] + [:lower [:min :u.last_name]]] + "profile_id" [:profile_id] + "ip_address" [:c.ip_address]}) (def ^:private list-query "HoneySQL query that selects one row per conversation with the aggregate @@ -102,7 +107,8 @@ :from [[:metabot_conversation :c]] :left-join [[:metabot_message :m] [:and [:= :m.conversation_id :c.id] - [:= :m.deleted_at nil]]] + [:= :m.deleted_at nil]] + [:core_user :u] [:= :u.id :c.user_id]] :group-by [:c.id]}) (defn- row->summary @@ -154,23 +160,25 @@ and a serialized `date` parameter string, plus sorting by an allow-listed `sort-by` column in either direction (defaults to newest-first)." [{:keys [limit offset user-id group-id tenant-id date sort-by sort-dir]}] - (let [limit (or limit default-limit) - offset (or offset default-offset) - where (list-where-clause {:user-id user-id - :group-id group-id - :tenant-id tenant-id - :date date}) - sort-col (get sort-columns sort-by :c.created_at) - direction (if (= sort-dir "asc") :asc :desc) - total (:count (t2/query-one (cond-> {:select [[[:count :*] :count]] - :from [[:metabot_conversation :c]]} - where (assoc :where where)))) - rows (t2/select :model/MetabotConversation - (cond-> (assoc list-query - :order-by [[sort-col direction] [:c.id :asc]] - :limit limit - :offset offset) - where (assoc :where where)))] + (let [limit (or limit default-limit) + offset (or offset default-offset) + where (list-where-clause {:user-id user-id + :group-id group-id + :tenant-id tenant-id + :date date}) + sort-exprs (get sort-columns sort-by [:c.created_at]) + direction (if (= sort-dir "asc") :asc :desc) + order-by (conj (mapv #(vector % direction) sort-exprs) + [:c.id :asc]) + total (:count (t2/query-one (cond-> {:select [[[:count :*] :count]] + :from [[:metabot_conversation :c]]} + where (assoc :where where)))) + rows (t2/select :model/MetabotConversation + (cond-> (assoc list-query + :order-by order-by + :limit limit + :offset offset) + where (assoc :where where)))] {:data (->> (t2/hydrate rows :user) hydrate-tool-counts (map row->summary)) diff --git a/enterprise/backend/src/metabase_enterprise/permission_debug/impl.clj b/enterprise/backend/src/metabase_enterprise/permission_debug/impl.clj index 3530fe945183..3276ec6dcebf 100644 --- a/enterprise/backend/src/metabase_enterprise/permission_debug/impl.clj +++ b/enterprise/backend/src/metabase_enterprise/permission_debug/impl.clj @@ -124,7 +124,6 @@ ;; Only include fields from responses that have the winning decision precedence acc-contributes? (= acc-precedence (decision-precedence winning-decision)) response-contributes? (= response-precedence (decision-precedence winning-decision))] - {:decision winning-decision :model-type (:model-type response) :model-id (:model-id response) diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/core.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/core.clj index f89dde6835ce..9b4ac3466a00 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/core.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/core.clj @@ -18,7 +18,6 @@ (p/import-vars [source] - [source.p ->ingestable]) diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/impl.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/impl.clj index f4215fcf234a..ff5899b58b9e 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/impl.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/impl.clj @@ -264,7 +264,6 @@ has-transforms? (snapshot-has-transforms? base-ingestable) {:keys [conflicts summary]} (get-conflicts base-ingestable first-import?) ingestable-snapshot (source.ingestable/wrap-progress-ingestable task-id 0.7 base-ingestable)] - (cond (and first-import? (not force?) (seq conflicts)) (u/prog1 {:status :conflict diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/init.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/init.clj index 035162436126..750fdc372491 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/init.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/init.clj @@ -49,7 +49,6 @@ (if (str/includes? (settings/remote-sync-allow) "overwrite-unpublished") (impl/async-import! branch true {}) (throw (ex-info "Remote sync is enabled with read-only type, but there are unpublished changes. To force an overwrite, set `MB_REMOTE_SYNC_ALLOW=overwrite-unpublished`" {})))))) - (when-not (collection/has-remote-synced-collection?) (if (nil? (settings/remote-sync-branch)) (log/warn "Remote sync is enabled but no remote-sync branch is set. Cannot do initial import.") diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/settings.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/settings.clj index bdbf418d186a..4fc3d9fb0976 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/settings.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/settings.clj @@ -181,7 +181,6 @@ (str/starts-with? remote-sync-url "https://")) (throw (ex-info "Invalid repository URL: only HTTPS URLs are supported (e.g., https://git-host.example.com/yourcompany/repo.git)" {:url remote-sync-url}))) - (let [source (git/git-source remote-sync-url "HEAD" remote-sync-token nil)] (when (and (= :read-only remote-sync-type) (not (str/blank? remote-sync-branch)) (not (some #{remote-sync-branch} (git/branches source)))) (throw (ex-info "Invalid branch name" {:url remote-sync-url :branch remote-sync-branch})))))) diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/source/git.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/source/git.clj index 8c48fc451c72..028e37701f9e 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/source/git.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/source/git.clj @@ -37,7 +37,6 @@ (defn- call-command [^GitCommand command] (let [analytics-labels {:operation (-> command .getClass .getSimpleName) :remote false}] (analytics/inc! :metabase-remote-sync/git-operations analytics-labels) - (try (.call command) (catch Exception e @@ -69,7 +68,6 @@ ;; For Gitlab any values can be used as the user name so x-access-token works just as well credentials-provider (when token (credentials-provider remote-url token))] (analytics/inc! :metabase-remote-sync/git-operations analytics-labels) - (try (-> command (.setCredentialsProvider credentials-provider) @@ -226,7 +224,6 @@ push-results (->> push-response (map #(into [] (.getRemoteUpdates ^PushResult %))) flatten)] - (when-let [failures (seq (remove #(#{RemoteRefUpdate$Status/OK RemoteRefUpdate$Status/UP_TO_DATE} %) (map #(.getStatus ^RemoteRefUpdate %) push-results)))] (throw (ex-info (str "Failed to push branch " branch-name " to remote") {:failures failures}))) push-response)) @@ -272,7 +269,6 @@ (let [repo (.getRepository git) branch-ref (qualify-branch branch) parent-id (.resolve repo version)] - (with-open [inserter (.newObjectInserter repo)] (let [index (DirCache/newInCore) builder (.builder index) @@ -281,7 +277,6 @@ (comp (map :path) (remove str/blank?)) files)] - ;; Add new/updated files to the index (doseq [{:keys [path content]} files :when (not (str/blank? path))] @@ -290,7 +285,6 @@ (.setFileMode FileMode/REGULAR_FILE) (.setObjectId blob-id))] (.add builder entry))) - ;; Copy existing tree entries that should be preserved: ;; - Outside managed directories AND not being overwritten by the write set ;; Files in managed dirs not in write-paths are dropped (stale file cleanup) @@ -309,9 +303,7 @@ (.setFileMode (.getFileMode tree-walk 0)) (.setObjectId (.getObjectId tree-walk 0)))] (.add builder entry)))))))) - (.finish builder) - ;; Create commit (let [tree-id (.writeTree index inserter) commit-builder (doto (CommitBuilder.) @@ -321,7 +313,6 @@ (.setMessage message))] (when parent-id (.setParentId commit-builder parent-id)) - (let [commit-id (.insert inserter commit-builder)] (.flush inserter) (doto (.updateRef repo branch-ref) diff --git a/enterprise/backend/src/metabase_enterprise/remote_sync/task/table_cleanup.clj b/enterprise/backend/src/metabase_enterprise/remote_sync/task/table_cleanup.clj index 25da529cbfa4..84919f09af72 100644 --- a/enterprise/backend/src/metabase_enterprise/remote_sync/task/table_cleanup.clj +++ b/enterprise/backend/src/metabase_enterprise/remote_sync/task/table_cleanup.clj @@ -52,6 +52,6 @@ (triggers/with-identity cleanup-trigger-key) (triggers/start-now) (triggers/with-schedule - ;; Run daily at 2:29 AM + ;; Run daily at 2:29 AM (cron/cron-schedule "0 29 2 * * ?")))] (task/schedule-task! job trigger))) diff --git a/enterprise/backend/src/metabase_enterprise/replacement/runner.clj b/enterprise/backend/src/metabase_enterprise/replacement/runner.clj index 718285be4108..facc151bc2d6 100644 --- a/enterprise/backend/src/metabase_enterprise/replacement/runner.clj +++ b/enterprise/backend/src/metabase_enterprise/replacement/runner.clj @@ -82,7 +82,6 @@ (eduction (comp (keep :definition) (map #(lib/query metadata-provider %))) (vals measures))))] - (when (seq queries) ;; Extract all referenced entity IDs across all queries (let [referenced-ids (lib/all-referenced-entity-ids queries)] @@ -115,7 +114,6 @@ :card (t2/select-one-fn :database_id :model/Card :id (second old-source)) :table (t2/select-one-fn :db_id :model/Table :id (second old-source))) batch-size 500] - ;; phase 1: Upgrade field refs for ALL transitive dependents (doseq [batch (partition-all batch-size all-transitive-dependents)] (lib-be/with-metadata-provider-cache @@ -126,14 +124,12 @@ ;; upgrade! knows how to handle all entity types including dashboards (replacement.field-refs/upgrade-field-refs! entity object) (replacement.protocols/advance! progress))))) - ;; phase 2: Swap sources for ALL transitive dependents (with batched metadata warming) (let [failures (atom [])] (doseq [batch (partition-all batch-size all-transitive-dependents)] (lib-be/with-metadata-provider-cache (let [metadata-provider (lib-be/application-database-metadata-provider db-id) loaded (bulk-load-metadata-for-entities! metadata-provider batch)] - (doseq [entity batch :let [object (get loaded entity)]] (try @@ -142,7 +138,6 @@ (log/warnf e "Failed to swap %s, continuing with next entity" entity) (swap! failures conj {:entity entity :error (ex-message e)}))) (replacement.protocols/advance! progress))))) - (when-let [fs (seq @failures)] (throw (ex-info (failure-message fs (count all-transitive-dependents)) {:failures fs})))))) @@ -204,17 +199,14 @@ ;; phase 1: execute the transform (transforms/execute! transform (cond-> {:run-method :manual} user-id (assoc :user-id user-id))) - ;; phase 2: find the output table, copy metadata overrides, and swap sources (let [table (or (transforms/output-table transform) (throw (ex-info "Output table not found after transform execution" {:transform-id (:id transform)})))] (copy-model-metadata-overrides! card-id (:id table)) (run-swap-source! [:card card-id] [:table (:id table)] progress)) - ;; phase 3: unpersist the model if it was persisted (when-let [persisted-info (t2/select-one :model/PersistedInfo :card_id card-id)] (model-persistence/mark-for-pruning! {:id (:id persisted-info)} "off")) - ;; phase 4: convert the model to a saved question (t2/update! :model/Card card-id {:type :question})))) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj index 14c3fd5ac403..211ddd8230d7 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/table.clj @@ -62,8 +62,8 @@ (if (only-sandboxed-perms? table) (filter-fields-for-sandboxing table - ;; if the user has sandboxed perms, temporarily upgrade their perms to read perms for the Table so they can - ;; fetch the metadata + ;; if the user has sandboxed perms, temporarily upgrade their perms to read perms for the Table so they can + ;; fetch the metadata (perms/with-additional-table-permission :perms/view-data (:db_id table) (u/the-id table) :unrestricted (perms/with-additional-table-permission :perms/create-queries (:db_id table) (u/the-id table) :query-builder (thunk)))) @@ -80,8 +80,8 @@ (if (only-sandboxed-perms? table) (filter-fields-for-sandboxing table - ;; if the user has sandboxed perms, temporarily upgrade their perms to read perms for the Table so they can - ;; fetch the metadata + ;; if the user has sandboxed perms, temporarily upgrade their perms to read perms for the Table so they can + ;; fetch the metadata (perms/with-additional-table-permission :perms/view-data (:db_id table) (u/the-id table) :unrestricted (perms/with-additional-table-permission :perms/create-queries (:db_id table) (u/the-id table) :query-builder table))) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/api/util.clj b/enterprise/backend/src/metabase_enterprise/sandbox/api/util.clj index ffa0dbbcc7af..0e92b7978920 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/api/util.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/api/util.clj @@ -87,8 +87,8 @@ (let [sandboxes (t2/hydrate (seq (perms/sandboxes-for-user)) :table)] (some #(= (get-in % [:table :db_id]) database-id) sandboxes)) - ;; If no *current-user-id* is bound we can't check for sandboxes, so we should throw in this case to avoid - ;; returning `false` for users who should actually be sandboxes. + ;; If no *current-user-id* is bound we can't check for sandboxes, so we should throw in this case to avoid + ;; returning `false` for users who should actually be sandboxes. (throw (ex-info (str (tru "No current user found")) {:status-code 403}))))) diff --git a/enterprise/backend/src/metabase_enterprise/sandbox/query_processor/middleware/sandboxing.clj b/enterprise/backend/src/metabase_enterprise/sandbox/query_processor/middleware/sandboxing.clj index 0770758ff03a..ac6b800d71b1 100644 --- a/enterprise/backend/src/metabase_enterprise/sandbox/query_processor/middleware/sandboxing.clj +++ b/enterprise/backend/src/metabase_enterprise/sandbox/query_processor/middleware/sandboxing.clj @@ -292,6 +292,39 @@ :qp/native-sandbox-column.propagate-coercion? true))))) original-table-cols)) +(defn- sandbox-exposed-field-ids + "Set of original-table field-ids the sandbox actually returns. MBQL GTAPs' returned columns carry `:id` from the + original table, so we take those ids directly. Native GTAPs' columns have only `:name` (no id), so bridge through + the original table's `:name → :id` mapping. The `:name` fallback is degenerate for tables with same-name columns — + those are unreachable through normal sync but we accept any matching id." + [sandbox-query original-table-id] + (let [sandbox-cols (lib/returned-columns sandbox-query) + direct-ids (into #{} (keep :id) sandbox-cols) + unresolved-names (into #{} + (comp (remove :id) (keep :name)) + sandbox-cols) + name-resolved-ids (when (seq unresolved-names) + (into #{} + (keep (fn [{col-name :name id :id}] + (when (and id (contains? unresolved-names col-name)) + id))) + (lib.metadata/fields sandbox-query original-table-id)))] + (into direct-ids name-resolved-ids))) + +(defn- filter-stage-fields-to-sandbox + "When wrapping a sandbox subquery with a stage that carries a `:fields` clause from before the swap, drop any + field-id refs the sandbox no longer exposes (e.g., a native GTAP that omits a column). Field refs by string name + refer to the previous stage's output and are preserved. See #73339." + [stage sandbox-field-ids] + (m/update-existing stage :fields + (fn [fields] + (filterv (fn [field-ref] + (let [[tag _opts id-or-name] field-ref] + (or (not= tag :field) + (not (integer? id-or-name)) + (contains? sandbox-field-ids id-or-name)))) + fields)))) + (mu/defn- apply-sandbox-to-stage :- [:and [:sequential {:min 1} ::lib.schema/stage] ::lib.schema.util/unique-uuids] @@ -328,8 +361,16 @@ (empty? (->> (keys stage) (remove #{:source-table :fields}) (remove qualified-keyword?)))) + ;; #73339: an implicit-join sub-stage gets `:fields` populated by the post-implicit-joins + ;; `add-implicit-clauses` pass *from the original table's column set*, then we land here and swap + ;; `:source-table` for the sandbox subquery. Any field-id refs the sandbox doesn't expose (e.g., a column + ;; the native GTAP drops) would compile to `SELECT __mb_source.` and fail at the DB. Filter them out. + wrapper-stage (when-not skip-final-stage? + (filter-stage-fields-to-sandbox + (dissoc stage :source-table) + (sandbox-exposed-field-ids sandbox-query source-table))) replacement-stages (cond-> new-source-stages - (not skip-final-stage?) (conj (dissoc stage :source-table)))] + wrapper-stage (conj wrapper-stage))] (log/tracef "Applied Sandbox: replaced stage\n\n%s\n\nwith stages\n\n%s" (u/cprint-to-str stage) (u/cprint-to-str replacement-stages)) diff --git a/enterprise/backend/src/metabase_enterprise/security_center/api.clj b/enterprise/backend/src/metabase_enterprise/security_center/api.clj index 552bec4f5f1a..0768d8499583 100644 --- a/enterprise/backend/src/metabase_enterprise/security_center/api.clj +++ b/enterprise/backend/src/metabase_enterprise/security_center/api.clj @@ -10,6 +10,7 @@ [metabase.api.common :as api] [metabase.api.macros :as api.macros] [metabase.api.routes.common :as routes.common :refer [+auth]] + [metabase.notification.models :as models.notification] [metabase.premium-features.core :as premium-features] [metabase.util.i18n :refer [tru]] [metabase.util.malli.schema :as ms] @@ -112,10 +113,25 @@ {:status (if submitted? "started" "already-in-progress")})) (api.macros/defendpoint :post "/test-notification" :- [:map [:success :boolean]] - "Send a test notification through the configured Security Center channels." - [] + "Send a test notification through the given Security Center channels. + + The request body lets callers pass the unsaved notification config from the + dialog so the test reflects current form state, not the persisted settings. + Both fields are optional; when omitted, the saved setting is used." + [_route-params + _query-params + body + :- [:map + [:email_recipients {:optional true} [:maybe [:sequential ::models.notification/NotificationRecipient]]] + [:slack_channel {:optional true} [:maybe :string]]]] (api/check-superuser) - (notification/send-test-notification!) + (notification/send-test-notification! + {:email-recipients (if (contains? body :email_recipients) + (:email_recipients body) + (settings/security-center-email-recipients)) + :slack-channel (if (contains? body :slack_channel) + (:slack_channel body) + (settings/security-center-slack-channel))}) {:success true}) (def +check-security-center-enabled diff --git a/enterprise/backend/src/metabase_enterprise/security_center/init.clj b/enterprise/backend/src/metabase_enterprise/security_center/init.clj index 89dd33f1faea..17d6ad93cf14 100644 --- a/enterprise/backend/src/metabase_enterprise/security_center/init.clj +++ b/enterprise/backend/src/metabase_enterprise/security_center/init.clj @@ -1,9 +1,11 @@ (ns metabase-enterprise.security-center.init (:require + [metabase-enterprise.security-center.metrics] [metabase-enterprise.security-center.models.security-advisory] [metabase-enterprise.security-center.settings] [metabase-enterprise.security-center.task.sync-advisories])) -(comment metabase-enterprise.security-center.models.security-advisory/keep-me +(comment metabase-enterprise.security-center.metrics/keep-me + metabase-enterprise.security-center.models.security-advisory/keep-me metabase-enterprise.security-center.settings/keep-me metabase-enterprise.security-center.task.sync-advisories/keep-me) diff --git a/enterprise/backend/src/metabase_enterprise/security_center/metrics.clj b/enterprise/backend/src/metabase_enterprise/security_center/metrics.clj new file mode 100644 index 000000000000..8f9cd5149bba --- /dev/null +++ b/enterprise/backend/src/metabase_enterprise/security_center/metrics.clj @@ -0,0 +1,65 @@ +(ns metabase-enterprise.security-center.metrics + "Prometheus metrics for the Security Center: advisory feed freshness and + vulnerability counts. Exposed so operators can alert when advisories stop + syncing or when vulnerabilities are present." + (:require + [metabase-enterprise.security-center.settings :as settings] + [metabase.analytics-interface.core :as analytics] + [metabase.analytics.core :as analytics.core] + [metabase.util.log :as log] + [toucan2.core :as t2]) + (:import + (java.time.temporal Temporal ChronoField))) + +(set! *warn-on-reflection* true) + +(def ^:private severities [:critical :high :medium :low]) + +(def ^:private vulnerable-statuses #{:active :error}) + +(defn- label-of [severity ack?] + {:severity (name severity) + :acknowledged (str ack?)}) + +(def ^:private vulnerable-advisory-labels + (vec (for [severity severities + ack? [true false]] + (label-of severity ack?)))) + +(defmethod analytics.core/known-labels :metabase-security-center/vulnerable-advisories + [_] + vulnerable-advisory-labels) + +(defn- last-sync-epoch-seconds + "Read the last-synced-at setting and convert to Unix epoch seconds, or nil if + no sync has ever completed." + [] + (when-let [^Temporal t (settings/security-center-last-synced-at)] + (.getLong t ChronoField/INSTANT_SECONDS))) + +(defn- vulnerable-counts + "Return a map of [severity acknowledged?] → count for advisories whose + match_status places them in the vulnerable bucket." + [] + (->> (t2/reducible-select [:model/SecurityAdvisory :severity :acknowledged_at] + :match_status [:in vulnerable-statuses]) + (reduce (fn [acc {:keys [severity acknowledged_at]}] + (update acc (label-of severity (some? acknowledged_at)) (fnil inc 0))) + {}))) + +(defn refresh-metrics! + "Recompute and set Security Center Prometheus gauges from the appdb." + [] + (try + (when-let [epoch (last-sync-epoch-seconds)] + (analytics/set-gauge! :metabase-security-center/last-sync-timestamp-seconds epoch)) + (catch Exception e + (log/warn e "Failed to set :metabase-security-center/last-sync-timestamp-seconds metric"))) + (try + (let [counts (vulnerable-counts)] + (doseq [label vulnerable-advisory-labels] + (analytics/set-gauge! :metabase-security-center/vulnerable-advisories + label + (get counts label 0)))) + (catch Exception e + (log/warn e "Failed to set :metabase-security-center/vulnerable-advisories metric")))) diff --git a/enterprise/backend/src/metabase_enterprise/security_center/notification.clj b/enterprise/backend/src/metabase_enterprise/security_center/notification.clj index d5b68ff159c5..f73396b8174d 100644 --- a/enterprise/backend/src/metabase_enterprise/security_center/notification.clj +++ b/enterprise/backend/src/metabase_enterprise/security_center/notification.clj @@ -6,8 +6,9 @@ Rather than going through the seeded event→notification pipeline, this namespace constructs notifications directly so it can resolve recipients dynamically from the `security-center-email-recipients` setting and the - `security-center-slack-channel` setting. The site admin email is always - included as a recipient when set." + `security-center-slack-channel` setting. The site admin email is included + as a recipient when set, but only if `security-center-email-recipients` + targets the admin group (i.e. \"Send to all instance admins\" is on)." (:require [metabase-enterprise.security-center.settings :as settings] [metabase.analytics.snowplow :as snowplow] @@ -15,6 +16,7 @@ [metabase.events.core :as events] [metabase.models.interface :as mi] [metabase.notification.core :as notification] + [metabase.permissions.core :as perms] [metabase.settings.core :as setting] [metabase.system.core :as system] [metabase.util.log :as log] @@ -37,35 +39,51 @@ :path "metabase/channel/email/security_advisory.hbs" :recipient-type "bcc"}}) -(defn- email-recipients - "Resolve email recipients: configured recipients from the setting, - plus the site admin email (if set) as a raw-value recipient." - [] - (let [configured (or (some-> (settings/security-center-email-recipients) - (t2/hydrate :recipients-detail)) - [])] - (if-let [admin-email (system/admin-email)] - (conj configured {:type :notification-recipient/raw-value - :details {:value admin-email}}) +(defn- sends-to-all-admins? + "True if the recipient list targets the admin group — i.e. \"Send to all + instance admins\" is on. Checked against the un-hydrated recipients so we + don't depend on hydration ordering." + [recipients] + (let [admin-group-id (:id (perms/admin-group))] + (boolean + (some (fn [{:keys [type permissions_group_id]}] + (and (= type :notification-recipient/group) + (= permissions_group_id admin-group-id))) + recipients)))) + +(defn- compute-email-recipients + "Resolve email recipients from the given configured list. When that list + targets the admin group (\"Send to all instance admins\" is on) and the + site admin email is set, the admin email is appended as a raw-value + recipient. When the toggle is off, only the explicitly configured + recipients are used." + [configured-recipients] + (let [raw (or configured-recipients []) + configured (or (some-> (not-empty raw) (t2/hydrate :recipients-detail)) + []) + admin-email (system/admin-email)] + (if (and admin-email (sends-to-all-admins? raw)) + (conj (vec configured) {:type :notification-recipient/raw-value + :details {:value admin-email}}) configured))) -(defn- slack-recipients - "Resolve Slack recipient from the `security-center-slack-channel` setting. - Returns a vector with a single raw-value recipient, or empty if Slack is not configured." - [] - (when-let [channel (settings/security-center-slack-channel)] - (when (setting/get-value-of-type :boolean :slack-token-valid?) - [{:type :notification-recipient/raw-value - :details {:value channel}}]))) +(defn- compute-slack-recipients + "Resolve Slack recipient from the given channel name. + Returns a vector with a single raw-value recipient, or nil if Slack is not configured." + [channel] + (when (and channel (setting/get-value-of-type :boolean :slack-token-valid?)) + [{:type :notification-recipient/raw-value + :details {:value channel}}])) (defn- build-handlers - "Build the notification handlers (email + optional Slack) with dynamically resolved recipients." - [] + "Build the notification handlers (email + optional Slack) from the given + configured email recipients and Slack channel." + [{:keys [email-recipients slack-channel]}] (let [handlers (when (channel.settings/email-configured?) [{:channel_type :channel/email :template email-template - :recipients (email-recipients)}])] - (if-let [slack-recipients (slack-recipients)] + :recipients (compute-email-recipients email-recipients)}])] + (if-let [slack-recipients (compute-slack-recipients slack-channel)] (conj handlers {:channel_type :channel/slack :recipients slack-recipients}) handlers))) @@ -73,11 +91,11 @@ (defn- build-notification "Build a notification map for a security advisory. This is a plain map (not a Toucan2 instance), so `send-notification!` skips DB hydration and uses it as-is." - [advisory] + [advisory config] {:payload_type :notification/system-event :payload {:event_info (advisory-event-info advisory) :event_topic :event/security-advisory-match} - :handlers (build-handlers)}) + :handlers (build-handlers config)}) (def ^:private channel-type->name "Map channel_type keywords to short names for Snowplow tracking." @@ -105,23 +123,31 @@ :remediation "No action required — this is only a test." :affected_versions []}) +(defn- saved-config + "Read the email recipients and Slack channel from the persisted settings." + [] + {:email-recipients (settings/security-center-email-recipients) + :slack-channel (settings/security-center-slack-channel)}) + (defn send-test-notification! - "Send a test notification through the configured channels so admins can verify + "Send a test notification through the given channels so admins can verify delivery without waiting for a real advisory. Does NOT publish an audit event - or update any advisory row." - [] - (let [handlers (build-handlers)] - (when (empty? handlers) + or update any advisory row. + + `config` is a map of `{:email-recipients [...] :slack-channel \"...\"}` + reflecting the (possibly unsaved) form state in the notification config dialog." + [config] + (let [notif (build-notification test-advisory config)] + (when (empty? (:handlers notif)) (throw (ex-info "No notification channels are configured." {:status-code 400}))) (log/info "Sending test security center notification") - (let [notif (build-notification test-advisory)] - (try - (notification/send-notification! notif :notification/sync? true) - (track-notification-sent! notif "test" "success") - (catch Exception e - (track-notification-sent! notif "test" "failure") - (throw e)))))) + (try + (notification/send-notification! notif :notification/sync? true) + (track-notification-sent! notif "test" "success") + (catch Exception e + (track-notification-sent! notif "test" "failure") + (throw e))))) (defn notify-advisory! "Send notifications for a security advisory and update `last_notified_at`. @@ -138,7 +164,7 @@ (advisory-event-info advisory)) ;; Send email + Slack via notification pipeline ;; sync, so failure doesn't set last_notified_at - (let [notif (build-notification advisory)] + (let [notif (build-notification advisory (saved-config))] (try (notification/send-notification! notif :notification/sync? true) (track-notification-sent! notif triggered-from "success") diff --git a/enterprise/backend/src/metabase_enterprise/security_center/task/sync_advisories.clj b/enterprise/backend/src/metabase_enterprise/security_center/task/sync_advisories.clj index af379a2196ca..3eba542c3502 100644 --- a/enterprise/backend/src/metabase_enterprise/security_center/task/sync_advisories.clj +++ b/enterprise/backend/src/metabase_enterprise/security_center/task/sync_advisories.clj @@ -9,6 +9,7 @@ [java-time.api :as t] [metabase-enterprise.security-center.fetch :as fetch] [metabase-enterprise.security-center.matching :as matching] + [metabase-enterprise.security-center.metrics :as metrics] [metabase-enterprise.security-center.notification :as notification] [metabase-enterprise.security-center.settings :as settings] [metabase.premium-features.core :as premium-features] @@ -63,6 +64,7 @@ (log/info "Syncing security advisories") (try (fetch/sync-advisories!) + (settings/security-center-last-synced-at! (t/offset-date-time)) (catch Exception e (log/warn e "Error fetching advisories from HM"))) (try @@ -73,7 +75,7 @@ (send-repeat-notifications!) (catch Exception e (log/warn e "Error sending repeat notifications"))) - (settings/security-center-last-synced-at! (t/offset-date-time)))) + (metrics/refresh-metrics!))) (task/defjob ^{:doc "Periodically fetch and re-evaluate security advisories." DisallowConcurrentExecution true} SyncAdvisories [_] @@ -95,6 +97,9 @@ (cron/cron-schedule cron-str) (cron/with-misfire-handling-instruction-do-nothing))))] (task/schedule-task! job trigger) + ;; Populate metrics from existing appdb state so freshness and vulnerability + ;; gauges survive restarts without waiting for the next scheduled sync. + (metrics/refresh-metrics!) ;; on first run of the feature, we sync immediately to seed advisories (when-not (settings/security-center-last-synced-at) (task/trigger-now! (jobs/key job-key)))))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/core.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/core.clj index 1460d07d6cf3..c6a2d79746c0 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/core.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/core.clj @@ -70,10 +70,8 @@ final-count threshold raw-count fallback) (analytics/inc! :metabase-search/semantic-fallback-triggered {:fallback-engine fallback}) (analytics/observe! :metabase-search/semantic-results-before-fallback final-count) - (when (some-> (:offset-int search-ctx) pos?) (log/warn "Using an offset with semantic search will produce strange results, e.g. missing expected results, or duplicating them across pages")) - (let [total-limit (semantic.settings/semantic-search-results-limit) fallback-results (try (cond->> (search.engine/results (assoc search-ctx :search-engine fallback)) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj index aee4a49d10c8..bcd3ccbbe01e 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/embedding.clj @@ -343,9 +343,7 @@ (get-embeddings-batch embedding-model batch-texts opts)) text-embedding-map (zipmap batch-texts embeddings)] (process-fn text-embedding-map)))] - (transduce (map-indexed process-batch) (partial merge-with +) batches)) - (let [embeddings (get-embeddings-batch embedding-model texts opts) text-embedding-map (zipmap texts embeddings)] (process-fn text-embedding-map)))))))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/gate.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/gate.clj index 3dcc39cd47e8..e8e4e6ecb5d2 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/gate.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/gate.clj @@ -140,11 +140,9 @@ ;; now deleted, delete if was not previously [:= nil :excluded.document_hash] [:!= nil (keyword gate-table-name "document_hash")] - ;; was deleted (now has a value) - update [:= nil (keyword gate-table-name "document_hash")] true - ;; update if new value is different :else [:!= (keyword gate-table-name "document_hash") :excluded.document_hash]]]}} @@ -170,14 +168,11 @@ start-time (u/start-timer) update-count (gate-documents!* pgvector index-metadata gate-document-batch) write-duration-ms (u/since-ms start-time)] - (analytics/observe! :metabase-search/semantic-gate-write-ms write-duration-ms) (analytics/inc! :metabase-search/semantic-gate-write-documents input-bounded-count) (analytics/inc! :metabase-search/semantic-gate-write-modified update-count) - (when (pos? update-count) (log/infof "Gated %d document updates in %.2f ms" update-count write-duration-ms)) - update-count)) (def ^:private epoch-timestamp (->timestamp Instant/EPOCH)) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj index 7810e1892a5d..1401cc9347b2 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/index.clj @@ -708,7 +708,12 @@ (defn- decode-legacy-input "Decode `row`s `:legacy_input` JSONB PGobject into a Clojure map." [row] - (update row :legacy_input decode-pgobject)) + ;; BOT-1543: some existing rows have legacy_input stored as a JSON string rather than a JSON + ;; object, so one decode yields a string; decode once more in that case. + (update row :legacy_input + (fn [pgo] + (let [decoded (decode-pgobject pgo)] + (cond-> decoded (string? decoded) json/decode+kw))))) ;; Search-models whose `mi/can-read?` is a pure function of `:collection_id`, which is ;; already denormalized on the index row. Values are the Toucan model whose `can-read?` @@ -755,16 +760,13 @@ slow-filtered (filterv (fn [doc] (some-> doc doc->t2 mi/can-read?)) slow-docs) result (into fast-filtered slow-filtered) time-ms (u/since-ms timer)] - (log/debug "Permission filtering" {:before-count (count docs) :after-count (count result) :fast-count (count fast-docs) :slow-count (count slow-docs) :slow-fetched-count (count slow-t2-instances) :time-ms time-ms}) - (analytics/inc! :metabase-search/semantic-permission-filter-ms time-ms) - result)) (defn- filter-by-collection-id @@ -848,7 +850,6 @@ (scoring/with-appdb-scores search-context appdb-scorers weights)) appdb-scores-time-ms (u/since-ms appdb-scores-timer) total-time-ms (u/since-ms timer)] - (log/debug "Semantic search" {:search-string-length (count search-string) :raw-results-count (count raw-results) @@ -858,7 +859,6 @@ :filter-time-ms filter-time-ms :appdb-scores-time-ms appdb-scores-time-ms :total-time-ms total-time-ms}) - (analytics/inc! :metabase-search/semantic-embedding-ms {:embedding-model (:name embedding-model)} embedding-time-ms) @@ -870,10 +870,8 @@ (analytics/inc! :metabase-search/semantic-search-ms {:embedding-model (:name embedding-model)} total-time-ms) - (comment (jdbc/execute! db (sql-format-quoted query))) - {:results final-results :raw-count (count raw-results)})))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/indexer.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/indexer.clj index c8e367a29328..2c823dc279ef 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/indexer.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/indexer.clj @@ -115,14 +115,12 @@ (if (seq last-seen-candidates) (into [] (remove last-seen-candidates) update-candidates) update-candidates))) - ;; currently we expect to flush the watermark each time we poll (move-to-next-watermark [poll-result] (let [{:keys [watermark]} @indexing-state next-watermark (semantic.gate/next-watermark watermark poll-result)] (vswap! indexing-state assoc :watermark next-watermark) (semantic.gate/flush-watermark! pgvector index-metadata index next-watermark))) - (clear-stall-if-needed [] (when (:stalled-at @indexing-state) (analytics/set-gauge! :metabase-search/semantic-indexer-stalled 0) @@ -142,12 +140,9 @@ updates (filter :document gate-docs) deletes (remove :document gate-docs) ^Timestamp stalled-at (:stalled-at @indexing-state)] - (when (seq novel-candidates) (analytics/inc! :metabase-search/semantic-indexer-read-documents-ms lookup-duration-ms)) - (when (seq gate-docs) (log/infof "Found gate updates: %d updates, %d deletes" (count updates) (count deletes))) - (cond ;; nothing to do (empty? gate-docs) @@ -214,7 +209,6 @@ (when (seq failures) (log/warnf "Adding %d indexing failures to the dead letter queue" (count failures)) (semantic.dlq/add-entries! pgvector index-metadata (:index-id @indexing-state) (map :dlq-entry failures))) - ;; once no longer failing, clear any stall from index_metadata ;; this will cause the 'healthy' branch to be used again. (when (empty? failures) @@ -222,11 +216,9 @@ stall-duration (Duration/between stalled-at (.instant clock))] (log/info "Indexer recovered from stall in" stall-duration)) (clear-stall-if-needed)) - ;; we progress the watermark regardless of success when stalled (move-to-next-watermark poll-result) (log/debugf "Processed %d gate documents in stalled mode for index %s, %d failed and moved to DLQ" (count gate-docs) (:table-name index) (count failures))))) - ;; we set :last-seen-candidates ;; to filter redundant entries from the last poll (duplicate delivery is expected and intended when ;; at the tail of the gate index). @@ -252,7 +244,6 @@ last-novel-count ^Instant next-dlq-run]} @indexing-state novelty-ratio (if (zero? last-poll-count) 0 (/ last-novel-count last-poll-count))] - (cond ;; if the next DLQ run should happen on the next iteration, we should not idle (and next-dlq-run (.isAfter (.instant clock) next-dlq-run)) @@ -354,25 +345,20 @@ :poll-limit dlq-poll-limit) now (.instant clock)] - (analytics/inc! :metabase-search/semantic-indexer-dlq-successes success-count) (analytics/inc! :metabase-search/semantic-indexer-dlq-failures failure-count) (analytics/inc! :metabase-search/semantic-indexer-dlq-loop-ms (.toMillis run-time)) - (log/debugf "DLQ step completed for index %s: exit-reason=%s, run-time=%s, successes=%d, failures=%d" (:table-name index) exit-reason run-time success-count failure-count) - (when (or (pos? success-count) (pos? failure-count)) (log/infof "Dead letter queue loop completed in %.2f seconds, %d successes, %d failures." (/ (.toMillis run-time) 1e3) success-count failure-count)) - ;; if we succeed we mark a DLQ success to hold off an early exit due to the exit-early-cold-duration elapsing ;; otherwise the indexing-loop will exit despite us having more to do in the DLQ. (when (pos? success-count) (vswap! indexing-state assoc :last-seen-change now)) - ;; schedule the next run, immediately on the next iteration if there is a lot to do. (let [next-run (if (and (pos? success-count) (= :ran-out-of-time exit-reason)) now @@ -381,7 +367,6 @@ (log/debugf "Scheduling next DLQ run for index %s immediately as there is more to retry" (:table-name index)) (log/debugf "Scheduling next DLQ run for index %s at %s" (:table-name index) next-run)) (vswap! indexing-state assoc :next-dlq-run next-run)) - nil)) (defn indexing-loop @@ -474,7 +459,6 @@ (when index (log/debugf "Starting indexer loop for index %s (ID: %s)" (:table_name metadata-row) (:id metadata-row)) (let [indexing-state (init-indexing-state metadata-row)] - ;; if the DLQ table exists, schedule runs to happen during indexing ;; note: we might remove the table-exists? condition once schema solidifies (if (semantic.dlq/dlq-table-exists? pgvector index-metadata (:id metadata-row)) @@ -482,7 +466,6 @@ (log/debugf "DLQ table exists for index %s, scheduling DLQ processing" (:table-name index)) (vswap! indexing-state assoc :next-dlq-run (.plus (.instant clock) dlq-frequency))) (log/warnf "DLQ table does not exist for index %s" (:table-name index))) - (try (let [loop-start-time (u/start-timer)] (indexing-loop @@ -491,7 +474,6 @@ index indexing-state) (analytics/inc! :metabase-search/semantic-indexer-loop-ms (u/since-ms loop-start-time))) - (catch InterruptedException ie (throw ie)) (catch Throwable t (log/errorf t "An exception was caught during the indexing loop for index %s" (:table-name metadata-row)) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/pgvector_api.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/pgvector_api.clj index 45ea4356acc4..199d04ac901d 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/pgvector_api.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/pgvector_api.clj @@ -54,14 +54,11 @@ {:index (fresh-index index-metadata embedding-model opts)})) index-id (or (:id metadata-row) (semantic.index-metadata/record-new-index-table! tx index-metadata index))] - (semantic.index/create-index-table-if-not-exists! tx index) (semantic.dlq/create-dlq-table-if-not-exists! tx index-metadata index-id) - (when-not active (log/infof "Configured model does not match active index, switching to new index %s" (u/pprint-to-str index)) (semantic.index-metadata/activate-index! tx index-metadata index-id)) - index)) (defn init-semantic-search! diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_cleanup.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_cleanup.clj index ef8a84460520..a538edd37063 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_cleanup.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_cleanup.clj @@ -188,7 +188,7 @@ (triggers/with-identity cleanup-trigger-key) (triggers/start-now) (triggers/with-schedule - ;; Run daily at 3 AM + ;; Run daily at 3 AM (cron/cron-schedule "0 0 3 * * ? *")))] (task/schedule-task! job trigger)))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_repair.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_repair.clj index 6adea36cf33e..01f1097b05ea 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_repair.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/task/index_repair.clj @@ -38,6 +38,6 @@ (triggers/with-identity repair-trigger-key) (triggers/start-now) (triggers/with-schedule - ;; Run hourly at minute 15 + ;; Run hourly at minute 15 (cron/cron-schedule "0 15 * * * ? *")))] (task/schedule-task! job trigger)))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/task/indexer.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/task/indexer.clj index 0048807f6071..50aed9377da4 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/task/indexer.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/task/indexer.clj @@ -42,8 +42,8 @@ (vreset! execution-thread-ref nil)))))))) org.quartz.InterruptableJob (interrupt [_] - ;; locking required here to avoid racing with the unset in the finally - ;; and interrupting some other unintended task/work + ;; locking required here to avoid racing with the unset in the finally + ;; and interrupting some other unintended task/work (locking execution-thread-ref (when-some [^Thread execution-thread @execution-thread-ref] (.interrupt execution-thread))))) diff --git a/enterprise/backend/src/metabase_enterprise/semantic_search/task/usage_trimmer.clj b/enterprise/backend/src/metabase_enterprise/semantic_search/task/usage_trimmer.clj index 049004ede023..2b04df57daed 100644 --- a/enterprise/backend/src/metabase_enterprise/semantic_search/task/usage_trimmer.clj +++ b/enterprise/backend/src/metabase_enterprise/semantic_search/task/usage_trimmer.clj @@ -41,6 +41,6 @@ (triggers/with-identity trimmer-trigger-key) (triggers/start-now) (triggers/with-schedule - ;; daily at 22:59:42 + ;; daily at 22:59:42 (cron/cron-schedule "42 59 22 * * ?")))] (task/schedule-task! job trigger)))) diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/backfill_ids.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/backfill_ids.clj index 5cd4f3932734..0d9a0476a0b2 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/v2/backfill_ids.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/backfill_ids.clj @@ -27,9 +27,9 @@ "Returns true if the model has an `:entity_id` column." [model] (or - ;; toucan1 models + ;; toucan1 models (isa? model ::mi/entity-id) - ;; toucan2 models + ;; toucan2 models (isa? model :hook/entity-id))) (defn backfill-ids! diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj index 7e186cec2b62..caf451a2c160 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/extract.clj @@ -8,6 +8,7 @@ [metabase-enterprise.serialization.v2.backfill-ids :as serdes.backfill] [metabase-enterprise.serialization.v2.models :as serdes.models] [metabase.collections.models.collection :as collection] + [metabase.config.core :as config] [metabase.models.serialization :as serdes] [metabase.util :as u] [metabase.util.log :as log] @@ -207,11 +208,22 @@ ;; extract all non-content entities like data model and settings if necessary (eduction (map #(serdes/extract-all % opts)) cat (remove (set serdes.models/content) models))]))))) +(defn- needs-version? + "True for extracted entities that should carry a `:metabase_version` stamp." + [entity] + (and (not (instance? Exception entity)) + (not= "Setting" (-> entity :serdes/meta last :model)))) + +(defn- stamp-version [entity] + (if (needs-version? entity) + (assoc entity :metabase_version config/mb-version-string) + entity)) + (defn extract "Returns a reducible stream of entities to serialize" [opts] (serdes.backfill/backfill-ids!) - (extract-subtrees opts)) + (eduction (map stamp-version) (extract-subtrees opts))) (comment (def nodes (let [colls (mapv vector (repeat "Collection") (collection-set-for-user nil))] diff --git a/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj b/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj index d2ab8f34ab65..f6ebed388901 100644 --- a/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj +++ b/enterprise/backend/src/metabase_enterprise/serialization/v2/load.clj @@ -3,10 +3,13 @@ See the detailed breakdown of the (de)serialization processes in [[metabase.models.serialization]]." (:require [clojure.string :as str] + [diehard.core :as dh] [medley.core :as m] [metabase-enterprise.serialization.v2.backfill-ids :as serdes.backfill] [metabase-enterprise.serialization.v2.ingest :as serdes.ingest] [metabase-enterprise.serialization.v2.models :as serdes.models] + [metabase.app-db.core :as mdb] + [metabase.app-db.transient-error :as transient-error] [metabase.config.core :as config] [metabase.models.serialization :as serdes] [metabase.search.core :as search] @@ -15,8 +18,25 @@ [toucan2.core :as t2] [toucan2.model :as t2.model])) +(set! *warn-on-reflection* true) + (declare load-one!) +(defn- with-retries + "Retries `f` up to `max-retries` times when it throws a transient DB error (deadlock, lock timeout, etc.). + Uses exponential backoff starting at `base-delay-ms`. Error classification is appdb-type-aware + via [[metabase.app-db.transient-error/transient-error?]]." + [max-retries base-delay-ms f] + (let [db-type (mdb/db-type)] + (dh/with-retry {:max-retries max-retries + :backoff-ms [base-delay-ms (* base-delay-ms (bit-shift-left 1 max-retries)) 2.0] + :retry-if (fn [_result exception] + (transient-error/transient-error? db-type exception)) + :on-retry (fn [_result exception] + (log/warnf "Transient DB error, retrying: %s" + (ex-message exception)))} + (f)))) + (def ^:private model->circular-dependency-keys "Sometimes models have circular dependencies. For example, a card for a Dashboard Question has a `dashboard_id` pointing to the dashboard it's in. But when we try to load that dashboard, we'll create all its dashcards, and one @@ -66,8 +86,8 @@ (defn- safe-local-id "Looks up the local primary key for `path`, swallowing any exception from the DB lookup. - [[path-error-data]] is called from catch blocks inside the outer load transaction. When the original - failure was a SQL error it will have poisoned the transaction, so any subsequent query (including + [[path-error-data]] is called from catch blocks. When the original failure was a SQL error it may + have poisoned the current transaction, so any subsequent query (including [[serdes/load-find-local]]) will throw `current transaction is aborted, commands ignored until end of transaction block` and shadow the real cause. We prefer losing `:local-id` over losing the exception chain." [path] @@ -102,12 +122,14 @@ (serdes.backfill/has-entity-id? model)))) (defn- warn-if-version-mismatch - "Checks if the version in the exported entity's serdes/meta differs from the current Metabase version. - Logs a warning if there is a mismatch." + "Checks if the version in the exported entity differs from the current Metabase version. + Logs a warning if there is a mismatch. Entities without a `:metabase_version` (eg. Settings, + which are bundled into settings.yaml without per-entity metadata) are skipped." [ingested path] - (when (or (nil? *warned-version-mismatch*) (not @*warned-version-mismatch*)) - (let [current-version config/mb-version-string - exported-version (or (:metabase_version ingested) "UNKNOWN")] + (when (and (or (nil? *warned-version-mismatch*) (not @*warned-version-mismatch*)) + (:metabase_version ingested)) + (let [current-version config/mb-version-string + exported-version (:metabase_version ingested)] (when (not= exported-version current-version) (log/warnf "Version mismatch loading %s: exported with: %s, current version: %s" path @@ -192,7 +214,10 @@ local-or-nil (when-not require-new-entity (serdes/load-find-local rebuilt-path))] (try (warn-if-version-mismatch ingested path) - (serdes/load-one! ingested local-or-nil) + (with-retries 3 200 + (fn [] + (t2/with-transaction [_tx] + (serdes/load-one! ingested local-or-nil)))) ctx (catch Exception e ;; ugly mapv here to convert #ordered/map into normal map so it's readable in the logs @@ -225,11 +250,14 @@ reindex? true}}] (binding [*warned-version-mismatch* (atom false)] (u/prog1 - (t2/with-transaction [_tx] - ;; We proceed in the arbitrary order of ingest-list, deserializing all the files. Their declared dependencies - ;; guide the import, and make sure all containers are imported before contents, etc. + ;; Each entity is loaded in its own transaction (inside load-one!), so a deadlock or transient + ;; failure on one entity doesn't abort the entire import. See #74412. + (do (when backfill? - (serdes.backfill/backfill-ids!)) + (t2/with-transaction [_tx] + (serdes.backfill/backfill-ids!))) + ;; We proceed in the arbitrary order of ingest-list, deserializing all the files. Their declared + ;; dependencies guide the import, and make sure all containers are imported before contents, etc. (let [contents (serdes.ingest/ingest-list ingestion) ingest-errors (serdes.ingest/ingest-errors ingestion) ctx (cond-> (new-context ingestion) @@ -255,8 +283,6 @@ ctx contents))) (when reindex? - ;; Hack: the transaction above typically takes much longer than our delay on the search indexing queue. - ;; this means that the corresponding entries would have been missing or stale when we indexed them. - ;; ideally, we would delay the indexing somehow, or only reindex what we've loaded. - ;; while we're figuring that out, here's a crude stopgap. + ;; Reindex after all entities are loaded. Individual entity commits may have produced stale + ;; search index entries; this ensures the index reflects the final state. (search/reindex!))))) diff --git a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj index 8108eea1aaf4..ccf3f01c1c54 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml.clj @@ -84,7 +84,6 @@ (let [redirect (get-in req [:params :redirect]) origin (get-in req [:headers "origin"]) embedding-sdk-header? (embed.util/is-modular-embedding-request? req)] - (cond ;; Case 1: Embedding SDK header is present - use ACS URL with token and origin embedding-sdk-header? @@ -179,7 +178,6 @@ (when (and token-value (not token-valid?)) (throw (ex-info (tru "Invalid authentication token") {:status-code 401}))) - (sso-utils/check-sso-redirect continue-url) (try (let [redirect-url (or continue-url (system/site-url)) @@ -191,7 +189,7 @@ ;; Login succeeded (:success? login-result) (if token-value - (saml-utils/create-token-response (:session login-result) origin clean-continue-url) + (saml-utils/create-token-response (:session login-result) origin clean-continue-url (:nonce request)) (request/set-session-cookies request (response/redirect (:redirect-url login-result)) (:session login-result) diff --git a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml_utils.clj b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml_utils.clj index 451f479fe582..3f2a53aea4b2 100644 --- a/enterprise/backend/src/metabase_enterprise/sso/integrations/saml_utils.clj +++ b/enterprise/backend/src/metabase_enterprise/sso/integrations/saml_utils.clj @@ -8,12 +8,12 @@ (set! *warn-on-reflection* true) (defn- generate-saml-html-popup - [key ^Instant exp ^Instant iat origin continue-url] + [key ^Instant exp ^Instant iat origin continue-url nonce] (str " Authentication Complete - "}]}]})))) - (testing "phishing link via crafted content JSON is stripped" (is (= "

Please re-authenticate

" (render/content->html {:type "doc" @@ -166,7 +150,6 @@ :text "Please re-authenticate" :marks [{:type "link" :attrs {:href "https://evil.example"}}]}]}]})))) - (testing "unknown mark types are stripped" (is (= "

text

" (render/content->html {:type "doc" @@ -174,7 +157,6 @@ :content [{:type "text" :text "text" :marks [{:type "evilMark"}]}]}]})))) - (testing "HTML in smartLink label is escaped" (mt/with-temporary-setting-values [site-url "http://localhost:3000"] (is (= "<img src=x>" @@ -183,7 +165,6 @@ :attrs {:entityId 1 :model "card" :label ""}}]}))))) - (testing "smartLink with unknown model renders as plain text" (is (= "My Thing" (render/content->html {:type "doc" @@ -199,7 +180,6 @@ :content [{:type "futureNodeType" :content [{:type "paragraph" :content [{:type "text" :text "still works"}]}]}]})))) - (testing "unknown node type without children is dropped" (is (= "" (render/content->html {:type "doc" diff --git a/test/metabase/dashboards/models/dashboard_tab_test.clj b/test/metabase/dashboards/models/dashboard_tab_test.clj index 9e22b35d162b..864742f569de 100644 --- a/test/metabase/dashboards/models/dashboard_tab_test.clj +++ b/test/metabase/dashboards/models/dashboard_tab_test.clj @@ -38,24 +38,20 @@ (mi/perms-objects-set dashboard :read))) (is (= (mi/perms-objects-set dashtab :write) (mi/perms-objects-set dashboard :write)))) - (testing (str "Check that if a Dashtab of a Dashboard is in a Collection, someone who would not be able to see it under the old " "artifact-permissions regime will be able to see it if they have permissions for that Collection") (binding [api/*current-user-permissions-set* (delay #{(perms/collection-read-path collection)})] (mi/perms-objects-set dashtab :read) (is (true? (mi/can-read? dashtab))) (is (= false (mi/can-write? dashtab))))) - (testing "Do we have *write* Permissions for a dashtab if we have *write* Permissions for the Collection it's in?" (binding [api/*current-user-permissions-set* (delay #{(perms/collection-readwrite-path collection)})] (is (true? (mi/can-read? dashtab))) (is (true? (mi/can-write? dashtab))))) - (testing "A user that can't see the Collection that the Dashboard is in can't read and write the dashtab" (mt/with-current-user (mt/user->id :lucky) (is (= false (mi/can-read? dashboard))) (is (= false (mi/can-write? dashboard))))) - (testing "A user that can see the Collection that the Dashboard is in can read and write the dashtab" (mt/with-current-user (mt/user->id :rasta) (is (true? (mi/can-read? dashboard))) @@ -66,7 +62,6 @@ (with-dashtab-in-personal-collection {:keys [dashtab dashcard]} (t2/delete! dashtab) (is (= nil (t2/select-one :model/DashboardCard :id (:id dashcard)))))) - (testing "Deleting a dashboard will delete all its dashcards" (with-dashtab-in-personal-collection {:keys [dashboard dashtab dashcard]} (t2/delete! dashboard) diff --git a/test/metabase/dashboards/models/dashboard_test.clj b/test/metabase/dashboards/models/dashboard_test.clj index 49d76f81a037..aac6f9301c61 100644 --- a/test/metabase/dashboards/models/dashboard_test.clj +++ b/test/metabase/dashboards/models/dashboard_test.clj @@ -180,7 +180,6 @@ (binding [api/*current-user-permissions-set* (atom #{(perms/collection-read-path collection)})] (is (true? (mi/can-read? dash))))) - (testing (str "Check that if a Dashboard is in a Collection, someone who would otherwise be able to see it under " "the old artifact-permissions regime will *NOT* be able to see it if they don't have permissions for " "that Collection")) @@ -188,7 +187,6 @@ (binding [api/*current-user-permissions-set* (atom #{})] (is (= false (mi/can-read? dash))))) - (testing "Do we have *write* Permissions for a Dashboard if we have *write* Permissions for the Collection its in?" (binding [api/*current-user-permissions-set* (atom #{(perms/collection-readwrite-path collection)})] (mi/can-write? dash))))) @@ -215,7 +213,6 @@ (t2/insert! :model/Dashboard (assoc (mt/with-temp-defaults :model/Dashboard) :collection_id collection-id, :name dashboard-name)))) (finally (t2/delete! :model/Dashboard :name dashboard-name))))) - (testing "Shouldn't be able to move a Dashboard to a non-normal Collection" (mt/with-temp [:model/Dashboard {card-id :id}] (is (thrown-with-msg? diff --git a/test/metabase/dashboards_rest/api_test.clj b/test/metabase/dashboards_rest/api_test.clj index 050aed0e8c27..da1bc0b87d92 100644 --- a/test/metabase/dashboards_rest/api_test.clj +++ b/test/metabase/dashboards_rest/api_test.clj @@ -66,20 +66,15 @@ :COLUMN_5 [{:sourceId "card:abc" :originalName "invalid" :name "COLUMN_5"}] :COLUMN_6 [{:name "No source ID"}]} result (#'api.dashboard/update-colvalmap-setting col->val-source id->new-card)] - (testing "should update valid card IDs that exist in the map" (is (= "card:456" (-> result :COLUMN_1 first :sourceId))) (is (= "card:987" (-> result :COLUMN_2 first :sourceId)))) - (testing "should not modify card IDs that don't exist in the map" (is (= "card:999" (-> result :COLUMN_3 first :sourceId)))) - (testing "should not modify non-card sourceIds" (is (= "not-a-card" (-> result :COLUMN_4 first :sourceId)))) - (testing "should not modify invalid card IDs (non-numeric)" (is (= "card:abc" (-> result :COLUMN_5 first :sourceId)))) - (testing "should handle items without sourceId" (is (= {:name "No source ID"} (-> result :COLUMN_6 first))))))) @@ -246,7 +241,6 @@ (is (=? {:collection_id true, :collection_position 1000} (some-> (t2/select-one [:model/Dashboard :collection_id :collection_position] :name dashboard-name) (update :collection_id (partial = (u/the-id collection)))))))) - (testing "..but not if we don't have permissions for the Collection" (mt/with-temp [:model/Collection collection] (let [dashboard-name (mt/random-name)] @@ -290,33 +284,26 @@ (-> (m/find-first #(= (:id %) crowberto-dash-id) (mt/user-http-request :crowberto :get 200 "dashboard" :f "mine")) (update-in [:last-edit-info :timestamp] boolean))))) - (testing "f=all shouldn't return archived dashboards" (is (set/subset? #{rasta-dash-id crowberto-dash-id} (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "all"))))) - (is (not (set/subset? #{archived-dash-id} (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "all")))))) - (testing "and should respect read perms" (is (set/subset? #{rasta-dash-id} (set (map :id (mt/user-http-request :rasta :get 200 "dashboard" :f "all"))))) - (is (not (set/subset? #{crowberto-dash-id archived-dash-id} (set (map :id (mt/user-http-request :rasta :get 200 "dashboard" :f "all")))))))) - (testing "f=archvied return archived dashboards" (is (= #{archived-dash-id} (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "archived"))))) - (testing "and should return read perms" (is (= #{} (set (map :id (mt/user-http-request :rasta :get 200 "dashboard" :f "archived"))))))) - (testing "f=mine return dashboards created by caller but do not include archived" (let [ids (set (map :id (mt/user-http-request :crowberto :get 200 "dashboard" :f "mine")))] (is (contains? ids crowberto-dash-id) "Should contain Crowberto's dashboard") @@ -593,7 +580,6 @@ {:url "https://metabase.com"}] (link-card-info-from-resp (mt/user-http-request :crowberto :get 200 (format "dashboard/%d" (:id dashboard)))))) - (testing "should return restricted if user doesn't have permission to view the models" (mt/with-no-data-perms-for-all-users! (is (= #{{:restricted true} {:url "https://metabase.com"}} @@ -740,11 +726,9 @@ (mt/with-temp [:model/Dashboard {dashboard-id :id dashboard-entity-id :entity_id} {:name "Test Dashboard"}] (with-dashboards-in-readable-collection! [dashboard-id] - (testing "GET /api/dashboard/:id works with entity ID" (is (=? {:name "Test Dashboard"} (dashboard-response (mt/user-http-request :rasta :get 200 (str "dashboard/" dashboard-entity-id)))))) - (testing "GET /api/dashboard/:id/query_metadata works with entity ID" (is (map? (mt/user-http-request :rasta :get 200 (str "dashboard/" dashboard-entity-id "/query_metadata"))))))))) @@ -789,7 +773,6 @@ :collection false :collection_id true} (dashboard-response (t2/select-one :model/Dashboard :id dashboard-id))))) - (testing "PUT response" (let [put-response (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) {:name "My Cool Dashboard" @@ -812,7 +795,6 @@ (testing "A PUT should return the updated value so a follow-on GET is not needed (#34828)" (is (= (update put-response :last-edit-info dissoc :timestamp) (update get-response :last-edit-info dissoc :timestamp)))))) - (testing "GET after update" (is (=? {:name "My Cool Dashboard" :description "Some awesome description" @@ -822,7 +804,6 @@ :collection_id true :view_count 1} (dashboard-response (t2/select-one :model/Dashboard :id dashboard-id))))) - (testing "No-op PUT: Do not return 500" (mt/with-temp [:model/Card {card-id :id} {} :model/DashboardCard dashcard {:card_id card-id, :dashboard_id dashboard-id}] @@ -882,7 +863,6 @@ (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:description nil}) (is (= nil (t2/select-one-fn :description :model/Dashboard :id (u/the-id dashboard)))) - (testing "Set to a blank description" (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:description ""}) (is (= "" @@ -936,12 +916,10 @@ (testing "the default dashboard width value is 'fixed'." (is (= "fixed" (t2/select-one-fn :width :model/Dashboard :id (u/the-id dashboard))))) - (testing "changing the width setting to 'full' works." (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:width "full"}) (is (= "full" (t2/select-one-fn :width :model/Dashboard :id (u/the-id dashboard))))) - (testing "values that are not 'fixed' or 'full' error." (is (=? {:specific-errors {:width ["should be either \"fixed\" or \"full\", received: 1200"]} :errors {:width "enum of fixed, full"}} @@ -955,7 +933,6 @@ (testing "the dashboard starts with no parameters." (is (= [] (t2/select-one-fn :parameters :model/Dashboard :id (u/the-id dashboard))))) - (testing "adding a new time granularity parameter works." (let [params [{:name "Time Unit" :slug "time_unit" @@ -966,7 +943,6 @@ (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:parameters params}) (is (= params (t2/select-one-fn :parameters :model/Dashboard :id (u/the-id dashboard)))))) - (testing "Update dashboard with parameters works (#50371)" (let [put-response (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:archived :true})] @@ -1033,13 +1009,11 @@ {:collection_position 1}) (is (= 1 (t2/select-one-fn :collection_position :model/Dashboard :id (u/the-id dashboard)))) - (testing "...and unset (unpin) it as well?" (mt/user-http-request :rasta :put 200 (str "dashboard/" (u/the-id dashboard)) {:collection_position nil}) (is (= nil (t2/select-one-fn :collection_position :model/Dashboard :id (u/the-id dashboard)))))) - (testing "we shouldn't be able to if we don't have permissions for the Collection" (mt/with-temp [:model/Collection collection {} :model/Dashboard dashboard {:collection_id (u/the-id collection)}] @@ -1047,7 +1021,6 @@ {:collection_position 1}) (is (= nil (t2/select-one-fn :collection_position :model/Dashboard :id (u/the-id dashboard))))) - (mt/with-temp [:model/Collection collection {} :model/Dashboard dashboard {:collection_id (u/the-id collection), :collection_position 1}] (mt/user-http-request :rasta :put 403 (str "dashboard/" (u/the-id dashboard)) @@ -1072,7 +1045,6 @@ (move-dashboard! b 4) (is (= {"a" 1, "c" 2, "d" 3, "b" 4} (items))))) - (testing "Check that updating a dashboard at position 3 to position 1 will increment the positions before 3, not after" (api.card-test/with-ordered-items collection [:model/Card a :model/Pulse b @@ -1081,7 +1053,6 @@ (move-dashboard! c 1) (is (= {"c" 1, "a" 2, "b" 3, "d" 4} (items))))) - (testing "Check that updating position 1 to 3 will cause b and c to be decremented" (api.card-test/with-ordered-items collection [:model/Dashboard a :model/Card b @@ -1090,7 +1061,6 @@ (move-dashboard! a 3) (is (= {"b" 1, "c" 2, "a" 3, "d" 4} (items))))) - (testing "Check that updating position 1 to 4 will cause a through c to be decremented" (api.card-test/with-ordered-items collection [:model/Dashboard a :model/Card b @@ -1099,7 +1069,6 @@ (move-dashboard! a 4) (is (= {"b" 1, "c" 2, "d" 3, "a" 4} (items))))) - (testing "Check that updating position 4 to 1 will cause a through c to be incremented" (api.card-test/with-ordered-items collection [:model/Card a :model/Pulse b @@ -1896,7 +1865,7 @@ :size_y 4 :col 1 :row 1 - ;; initialy was in tab1, now in tab 2 + ;; initialy was in tab1, now in tab 2 :dashboard_tab_id dashtab-id-2 :card_id card-id-1} {:id dashcard-id-2 @@ -1918,7 +1887,6 @@ (is (= "Updated dashboard name" (t2/select-one-fn :name :model/Dashboard :id dashboard-id) (:name resp)))) - (testing "tabs got updated correctly " (is (=? [{:id dashtab-id-1 :dashboard_id dashboard-id @@ -1935,7 +1903,6 @@ (:tabs resp))) (testing "dashtab 3 got deleted" (is (nil? (t2/select-one :model/DashboardTab :id dashtab-id-3))))) - (testing "dashcards got updated correctly" (let [new-tab-id (t2/select-one-pk :model/DashboardTab :name "New tab" :dashboard_id dashboard-id)] (is (=? [{:id dashcard-id-1 @@ -2051,7 +2018,6 @@ {:name "Tab 1 moved to second position" :id dashtab-id-1}] :dashcards []}))] - (is (=? [{:dashboard_id dashboard-id :name "Tab new" :position 0} @@ -2192,7 +2158,6 @@ "event" "dashboard_tab_created"} :user-id (str (mt/user->id :rasta))}] (take-last 2 (snowplow-test/pop-event-data-and-user-id!)))))) - (testing "send nothing if tabs are unchanged" (snowplow-test/with-fake-snowplow-collector (mt/user-http-request :rasta :put 200 (format "dashboard/%d" dashboard-id) @@ -2264,7 +2229,6 @@ :card_id card-id :target [:dimension [:field-id (mt/id :venues :id)]]}]}]}))] (is (some? (t2/select-one :model/DashboardCard (:id (first resp)))))))) - (testing "PUT /api/dashboard/:id/cards accepts expression as parammeter's target" (mt/with-temp [:model/Dashboard {dashboard-id :id} {} :model/Card {card-id :id} {:dataset_query (mt/mbql-query venues {:expressions {"A" [:+ (mt/$ids $venues.price) 1]}})}] @@ -2567,7 +2531,6 @@ :tabs []}))) (is (= 1 (count (t2/select-pks-set :model/DashboardCard, :dashboard_id dashboard-id))))))) - (testing "prune" (mt/with-temp [:model/Dashboard {dashboard-id :id} {} :model/Card {card-id :id} {} @@ -2647,17 +2610,14 @@ (is (= uuid (:uuid (mt/user-http-request :crowberto :post 200 (format "dashboard/%d/public_link" (u/the-id dashboard)))))))))) - (mt/with-temp [:model/Dashboard dashboard] (testing "Test that we *cannot* share a Dashboard if we aren't admins" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 (format "dashboard/%d/public_link" (u/the-id dashboard)))))) - (testing "Test that we *cannot* share a Dashboard if the setting is disabled" (mt/with-temporary-setting-values [enable-public-sharing false] (is (= "Public sharing is not enabled." (mt/user-http-request :crowberto :post 400 (format "dashboard/%d/public_link" (u/the-id dashboard)))))))) - (testing "Test that we get a 404 if the Dashboard doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :post 404 (format "dashboard/%d/public_link" Integer/MAX_VALUE))))))) @@ -2670,17 +2630,14 @@ (mt/user-http-request :crowberto :delete 204 (format "dashboard/%d/public_link" (u/the-id dashboard))) (is (= false (t2/exists? :model/Dashboard :id (u/the-id dashboard), :public_uuid (:public_uuid dashboard)))))) - (testing "Test that we *cannot* unshare a Dashboard if we are not admins" (mt/with-temp [:model/Dashboard dashboard (shared-dashboard)] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "dashboard/%d/public_link" (u/the-id dashboard))))))) - (testing "Test that we get a 404 if Dashboard isn't shared" (mt/with-temp [:model/Dashboard dashboard] (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "dashboard/%d/public_link" (u/the-id dashboard))))))) - (testing "Test that we get a 404 if Dashboard doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "dashboard/%d/public_link" Integer/MAX_VALUE)))))))) @@ -2731,7 +2688,6 @@ [[{:card {:dataset_query (fake-query 1)}} ["S7xKRDQIVA4k/rzNGAc6PyMCvMiYs2MTkAJK5gwBGHU=" "F2yzgei1xfnQhNcakBq9c/q3lg0K9QDtWUHYOKGpBsM="]] - [{:card {:dataset_query (fake-query 2)} :series [{:dataset_query (fake-query 3)} {:dataset_query (fake-query 4)}]} @@ -2959,7 +2915,6 @@ (binding [qp.perms/*card-id* nil] ;; this situation was observed when running constrained chain filters. (is (= {:values [["African"] ["American"] ["Artisan"] ["Asian"]] :has_more_values false} (chain-filter-test/take-n-values 4 (mt/user-http-request :rasta :get 200 url))))))))) - (let [url (chain-filter-values-url dashboard (:category-name param-keys) (:price param-keys) 4)] (testing (str "\nGET /api/" url "\n") (testing "\nShow me names of categories that have expensive venues (price = 4), while I lack permissions." @@ -3003,7 +2958,6 @@ (mt/user-http-request :rasta :get 403 (chain-filter-values-url (:id dashboard) (:category-name param-keys))))))))) - (testing "Should work if Dashboard has multiple mappings for a single param" (with-chain-filter-fixtures [{:keys [dashboard card dashcard param-keys]}] (mt/with-temp [:model/Card card-2 (dissoc card :id :entity_id) @@ -3015,7 +2969,6 @@ (->> (chain-filter-values-url (:id dashboard) (:category-name param-keys)) (mt/user-http-request :rasta :get 200) (chain-filter-test/take-n-values 3))))))) - (testing "should check perms for the Fields in question" (mt/with-temp-copy-of-db (with-chain-filter-fixtures [{:keys [dashboard param-keys]}] @@ -3031,7 +2984,6 @@ (->> (chain-filter-values-url (:id dashboard) (:category-name param-keys)) (mt/user-http-request :rasta :get 200) (chain-filter-test/take-n-values 3))))))))) - (testing "missing data perms should not affect perms for the Fields in question when users have collection access" (mt/with-temp-copy-of-db (with-chain-filter-fixtures [{:keys [dashboard param-keys]}] @@ -3478,7 +3430,6 @@ (is (= {:values [["African"] ["American"] ["Artisan"] ["Asian"] ["BBQ"]] :has_more_values false} (mt/user-http-request :rasta :get 200 url))))) - (testing "it only returns search matches" (mt/let-url [url (chain-filter-search-url dashboard (:card param-keys) "afr")] (is (= {:values [["African"]] @@ -3557,11 +3508,11 @@ ;; This test should either (1) be rehabilitated to use Lib to get the set of columns for filtering a dashcard (like ;; the FE); or (2) just be dropped if it's not providing value. #_(testing "field selection should compatible with field-id from /api/table/:card__id/query_metadata" - ;; FE use the id returned by /api/table/:card__id/query_metadata - ;; for the `values_source_config.value_field`, so we need to test to make sure - ;; the id is a valid field that we could use to retrieve values. + ;; FE use the id returned by /api/table/:card__id/query_metadata + ;; for the `values_source_config.value_field`, so we need to test to make sure + ;; the id is a valid field that we could use to retrieve values. (mt/with-temp - ;; card with agggregation and binning columns + ;; card with agggregation and binning columns [Card {mbql-card-id :id} (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues {:limit 5 @@ -3575,7 +3526,6 @@ {:name "Native question" :database_id (mt/id) :table_id (mt/id :venues)})] - (let [mbql-card-fields (card-fields-from-table-metadata mbql-card-id) native-card-fields (card-fields-from-table-metadata native-card-id) _ (prn "mbql-card-fields" mbql-card-fields) @@ -3657,7 +3607,7 @@ (mt/user-http-request :rasta :get 200 (format "/dashboard/%d/params/%s/search/%s" (:id dashboard) "_text_" - ;; a0 is part of first 2 rows of queried table + ;; a0 is part of first 2 rows of queried table "a0"))))))))) (deftest field-filter-uuid-operator-dashboard-test @@ -3816,7 +3766,6 @@ (mt/user-http-request :rasta :post 202 url {:parameters [{:id "_PRICE_" :value 4}]}))))) - ;; don't let people try to be sneaky and get around our validation by passing in a different `:target` (testing "Should ignore incorrect `:target` passed in to API endpoint" (is (malli= (dashboard-card-query-expected-results-schema :row-count 6) @@ -4706,19 +4655,16 @@ (testing "Notification emails were sent to the dashboard and pulse creators" (emails-received? "rasta@metabase.com") (emails-received? "trashbird@metabase.com")))))))) - (testing "When a subscriptions is broken, archive it and notify the dashboard and subscription creator (#30100)" (test-handle-broken-subscription-notification! {:disable-links? false :email-body-pattern "#my-channel" :match-email-body-pattern? true})) - (testing "When a subscriptions is broken, archive it and notify the dashboard and subscription creator (#30100) with email links when disable_links: false" (test-handle-broken-subscription-notification! {:disable-links? false :email-body-pattern "href=" :match-email-body-pattern? true})) - (testing "When a subscriptions is broken, archive it and notify the dashboard and subscription creator (#30100) without email links when disable_links: true" (test-handle-broken-subscription-notification! {:disable-links? true @@ -4883,7 +4829,6 @@ :model/Dashboard {dashboard-id :id} {} :model/DashboardCard _ {:card_id card-id-2 :dashboard_id dashboard-id}] - (letfn [(query-metadata [] (-> (mt/user-http-request :crowberto :get 200 (str "dashboard/" dashboard-id "/query_metadata")) (api.test-util/select-query-metadata-keys-for-debugging)))] @@ -5056,7 +5001,6 @@ ;; If we need more for _some reason_, this test should be updated accordingly. (testing "At most 1 db call should be executed for :metadata/tables" (is (<= @cached-calls-count 1))) - (testing "dashboard card /query calls reuse metadata providers" (let [providers (atom []) load-id (str (random-uuid))] @@ -5305,7 +5249,6 @@ (testing "Initial parameter cards are created" (is (= 1 (t2/count :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)))) - (testing "Dashboard update with unchanged parameters preserves parameter cards" (let [original-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" @@ -5313,7 +5256,6 @@ (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) {:name "Updated Dashboard Name" :description "New description"}) - (let [updated-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)] @@ -5342,7 +5284,6 @@ (testing "Initial parameter cards are created for card-sourced parameters only" (is (= 1 (t2/count :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)))) - (testing "Dashboard update with identical parameters preserves parameter cards" (let [original-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" @@ -5351,7 +5292,6 @@ (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) {:parameters original-parameters :description "Updated description"}) - (let [updated-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)] @@ -5383,7 +5323,6 @@ (testing "Initial parameter cards are created" (is (= 2 (t2/count :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)))) - (testing "Update with one parameter unchanged, one parameter changed" (mt/user-http-request :rasta :put 200 (str "dashboard/" dashboard-id) {:parameters [{:name "Category" @@ -5398,7 +5337,6 @@ :type "category" :values_source_type "card" :values_source_config {:card_id source-card-id-2}}]}) - (let [param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)] @@ -5421,7 +5359,6 @@ (testing "Initial parameter cards are created" (is (= 1 (t2/count :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)))) - (testing "Dashboard update without parameters field preserves parameter cards" (let [original-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" @@ -5431,7 +5368,6 @@ {:name "Updated Name" :description "Updated description" :cache_ttl 3600}) - (let [updated-param-cards (t2/select :model/ParameterCard :parameterized_object_type "dashboard" :parameterized_object_id dashboard-id)] diff --git a/test/metabase/data_studio/api/table_test.clj b/test/metabase/data_studio/api/table_test.clj index ff4b3096a707..2464ee33bbd1 100644 --- a/test/metabase/data_studio/api/table_test.clj +++ b/test/metabase/data_studio/api/table_test.clj @@ -19,7 +19,6 @@ (mt/with-temp [:model/Database {db-id :id} {} :model/Table {table-1-id :id} {:db_id db-id} :model/Table {table-2-id :id} {:db_id db-id}] - (testing "updating data_layer syncs to visibility_type for all tables" ;; Update two tables to internal, which should sync to nil visibility_type (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" @@ -29,7 +28,6 @@ (is (= nil (t2/select-one-fn :visibility_type :model/Table :id table-1-id))) (is (= :internal (t2/select-one-fn :data_layer :model/Table :id table-2-id))) (is (= nil (t2/select-one-fn :visibility_type :model/Table :id table-2-id)))) - (testing "updating data_layer to hidden syncs to hidden visibility_type" ;; Update one table back to hidden, which should sync to :hidden (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" @@ -37,7 +35,6 @@ :data_layer "hidden"}) (is (= :hidden (t2/select-one-fn :data_layer :model/Table :id table-1-id))) (is (= :hidden (t2/select-one-fn :visibility_type :model/Table :id table-1-id)))) - (testing "cannot update both visibility_type and data_layer at once" (mt/user-http-request :crowberto :post 400 "data-studio/table/edit" {:table_ids [table-1-id] @@ -196,35 +193,28 @@ :model/Table {table-3 :id} {:db_id db-2, :schema "schema-a"} :model/Table {table-4 :id} {:db_id db-2, :schema "schema-b"} :model/Table {table-5 :id} {:db_id db-2}] - (testing "filter by database_ids" (is (= #{table-1 table-2} (selectors->table-ids {:database_ids [db-1]})))) - (testing "filter by table_ids" (is (= #{table-3 table-4} (selectors->table-ids {:table_ids [table-3 table-4]})))) - (testing "filter by schema_ids" (is (= #{table-2} (selectors->table-ids {:schema_ids [(format "%d:schema-a" db-1)]})))) - (testing "filter by multiple schema_ids" (is (= #{table-3 table-4} (selectors->table-ids {:schema_ids [(format "%d:schema-a" db-2) (format "%d:schema-b" db-2)]})))) - (testing "combine database_ids and table_ids (OR logic)" (is (= #{table-1 table-2 table-3} (selectors->table-ids {:database_ids [db-1] :table_ids [table-3]})))) - (testing "combine all selectors (OR logic)" (is (= #{table-1 table-2 table-4 table-5} (selectors->table-ids {:database_ids [db-1] :table_ids [table-5] :schema_ids [(format "%d:schema-b" db-2)]})))) - (testing "empty selectors returns no tables" (is (= nil (selectors->table-ids {})))))))) @@ -244,21 +234,18 @@ {:table_ids [hidden-1 hidden-2] :data_layer "final"}) (is (= #{hidden-1 hidden-2} @synced-ids))) - (testing "Changing from hidden to internal triggers sync" (reset! synced-ids #{}) (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:table_ids [hidden-3] :data_layer "internal"}) (is (= #{hidden-3} @synced-ids))) - (testing "Not changing from hidden does not trigger sync" (reset! synced-ids #{}) (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:table_ids [internal-1] :data_layer "final"}) (is (= #{} @synced-ids))) - (testing "Changing to hidden does not trigger sync" (reset! synced-ids #{}) (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" @@ -276,13 +263,11 @@ :model/Table {classes :id} {:db_id jvm} :model/Table {gc :id} {:db_id jvm, :schema "jre"} :model/Table {jit :id} {:db_id jvm, :schema "jre"}] - (testing "only admin can edit" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "data-studio/table/edit" {:database_ids [clojure jvm] :data_layer "hidden"})))) - (testing "simple happy path updating with db ids" (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:database_ids [clojure jvm] @@ -292,7 +277,6 @@ (is (= #{:hidden} (t2/select-fn-set :data_layer :model/Table :db_id [:in [clojure jvm]]))) (is (= #{:authoritative} (t2/select-fn-set :data_authority :model/Table :db_id [:in [clojure jvm]]))) (is (= #{:ingested} (t2/select-fn-set :data_source :model/Table :db_id [:in [clojure jvm]])))) - (testing "updating with all selectors" (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:database_ids [clojure] @@ -306,14 +290,11 @@ gc :internal jit :internal} (t2/select-pk->fn :data_layer :model/Table :db_id [:in [clojure jvm]])))) - (testing "can update owner_email" (is (= #{nil} (t2/select-fn-set :owner_email :model/Table :db_id [:in [clojure jvm]]))) - (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:database_ids [clojure] :owner_email "clojure-owner@example.com"}) - (is (= {vars "clojure-owner@example.com" namespaces "clojure-owner@example.com" beans nil @@ -321,14 +302,11 @@ gc nil jit nil} (t2/select-pk->fn :owner_email :model/Table :db_id [:in [clojure jvm]])))) - (testing "can update owner_user_id" (is (= #{nil} (t2/select-fn-set :owner_user_id :model/Table :db_id [:in [clojure jvm]]))) - (mt/user-http-request :crowberto :post 200 "data-studio/table/edit" {:table_ids [beans classes] :owner_user_id (mt/user->id :rasta)}) - (is (= {vars nil namespaces nil beans (mt/user->id :rasta) @@ -410,7 +388,6 @@ :display_name "Products" :schema "PUBLIC"}]} response)))) - (testing "if products is already published, it appears in published_downstream when selecting it" (t2/update! :model/Table products-id {:is_published true}) (let [response (mt/user-http-request :crowberto :post 200 "data-studio/table/selection" @@ -426,7 +403,6 @@ ;; when we want to unpublish products, orders would need to be unpublished too ;; but orders is already unpublished, so published_downstream_tables should be empty (is (= [] (:published_downstream_tables response))))) - (testing "when orders is published and we select products, orders appears in published_downstream" (t2/update! :model/Table orders-id {:is_published true}) (t2/update! :model/Table products-id {:is_published true}) diff --git a/test/metabase/documents/api/collection_test.clj b/test/metabase/documents/api/collection_test.clj index 37712917d0f1..bd645354e3b2 100644 --- a/test/metabase/documents/api/collection_test.clj +++ b/test/metabase/documents/api/collection_test.clj @@ -135,7 +135,6 @@ (is (contains? item-ids ["card" pinned-card-id])) (is (not (contains? item-ids ["document" unpinned-doc-id]))) (is (not (contains? item-ids ["card" unpinned-card-id]))))) - (testing "pinned_state=is_not_pinned returns only unpinned documents and cards" (let [items (:data (mt/user-http-request :rasta :get 200 (str "collection/" coll-id "/items?pinned_state=is_not_pinned"))) @@ -144,7 +143,6 @@ (is (not (contains? item-ids ["card" pinned-card-id]))) (is (contains? item-ids ["document" unpinned-doc-id])) (is (contains? item-ids ["card" unpinned-card-id])))) - (testing "pinned_state=all returns all documents and cards" (let [items (:data (mt/user-http-request :rasta :get 200 (str "collection/" coll-id "/items?pinned_state=all"))) @@ -175,7 +173,6 @@ (is (contains? test-item-ids ["card" pinned-card-id])) (is (not (contains? test-item-ids ["document" unpinned-doc-id]))) (is (not (contains? test-item-ids ["card" unpinned-card-id]))))) - (testing "pinned_state=is_not_pinned returns only unpinned root documents and cards" (let [items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?pinned_state=is_not_pinned")) test-item-ids (set (map (juxt :model :id) items))] @@ -206,7 +203,6 @@ (is (some #(= 1 %) doc-positions)) (is (some nil? doc-positions)) (is (some #(= 3 %) doc-positions)))) - (testing "Pinned documents have higher collection_position values and appear before unpinned" (let [items (:data (mt/user-http-request :rasta :get 200 (str "collection/" coll-id "/items?pinned_state=is_pinned"))) diff --git a/test/metabase/documents/api/document_test.clj b/test/metabase/documents/api/document_test.clj index 34ec3798c1b6..6064b107603f 100644 --- a/test/metabase/documents/api/document_test.clj +++ b/test/metabase/documents/api/document_test.clj @@ -162,7 +162,6 @@ {:type "paragraph"} {:type "cardEmbed" :attrs {:id card-2-id :name nil}} {:type "cardEmbed" :attrs {:id archived-card-id :name nil}}]}}) - (let [copied (mt/user-http-request :crowberto :post 200 (format "document/%d/copy" doc-id) {:name "Copied Document" @@ -172,12 +171,10 @@ (is (not= doc-id (:id copied))) (is (= "Copied Document" (:name copied))) (is (= coll-id (:collection_id copied)))) - (testing "copies cards onto the new document" (let [new-cards (t2/select :model/Card :document_id (:id copied))] (is (= 3 (count new-cards))) (is (every? #(not (contains? #{card-1-id card-2-id archived-card-id} (:id %))) new-cards)))) - (testing "updates embedded card IDs in the copied document AST for all copied cards" (let [new-cards-by-name (into {} (map (juxt :name :id)) (t2/select :model/Card :document_id (:id copied))) embedded-ids (keep #(get-in % [:attrs :id]) (get-in copied [:document :content]))] @@ -197,7 +194,6 @@ (mt/user-http-request :rasta :post 403 (format "document/%d/copy" doc-id) {:name "Should Not Copy"})))) - (testing "fails with 403 when user cannot create the new document in destination collection" (mt/with-temp [:model/Collection {allowed-col :id} {} :model/Collection {restricted-dest-col :id} {} @@ -249,34 +245,27 @@ :type :question :collection_id old-collection-id :dataset_query (mt/mbql-query venues)}] - (testing "PUT /api/document/:id with collection change syncs cards" ;; Update document through API to move to new collection (let [response (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) {:collection_id new-collection-id})] (is (= new-collection-id (:collection_id response))) - ;; Verify document was moved (is (= new-collection-id (:collection_id (t2/select-one :model/Document :id document-id)))) - ;; Verify associated cards were moved (is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id)))) (is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id)))) - ;; Verify other card was NOT moved (is (= old-collection-id (:collection_id (t2/select-one :model/Card :id other-card-id)))))) - (testing "Moving to root collection (nil) works" (let [response (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) {:collection_id nil})] (is (nil? (:collection_id response))) - ;; Verify all associated cards moved to root (is (nil? (:collection_id (t2/select-one :model/Card :id card1-id)))) (is (nil? (:collection_id (t2/select-one :model/Card :id card2-id)))) - ;; Other card should still be in old collection (is (= old-collection-id (:collection_id (t2/select-one :model/Card :id other-card-id)))))))))) @@ -293,7 +282,6 @@ :document_id document-id :collection_id collection1-id :dataset_query (mt/mbql-query venues)}] - (testing "regular user without permissions cannot move document" ;; Should fail with 403 (mt/with-non-admin-groups-no-collection-perms collection1-id @@ -301,38 +289,31 @@ (mt/user-http-request user-id :put 403 (format "document/%s" document-id) {:collection_id collection2-id}) - - ;; Verify nothing changed + ;; Verify nothing changed (is (= collection1-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= collection1-id (:collection_id (t2/select-one :model/Card :id card-id))))))) - (testing "regular user without source permissions cannot move document" ;; Should fail with 403 (mt/with-non-admin-groups-no-collection-perms collection1-id (mt/user-http-request user-id :put 403 (format "document/%s" document-id) {:collection_id collection2-id}) - ;; Verify nothing changed (is (= collection1-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= collection1-id (:collection_id (t2/select-one :model/Card :id card-id)))))) - (testing "regular user without destination permissions cannot move document" ;; Should fail with 403 (mt/with-non-admin-groups-no-collection-perms collection2-id (mt/user-http-request user-id :put 403 (format "document/%s" document-id) {:collection_id collection2-id}) - ;; Verify nothing changed (is (= collection1-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= collection1-id (:collection_id (t2/select-one :model/Card :id card-id)))))) - (testing "moving to non-existent collection fails gracefully" (mt/user-http-request :crowberto :put 400 (format "document/%s" document-id) {:collection_id 99999}) - ;; Verify nothing changed (is (= collection1-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= collection1-id (:collection_id (t2/select-one :model/Card :id card-id))))))))) @@ -364,40 +345,33 @@ {:type "paragraph"}]} :collection_id col-id :cards cards-to-create})] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Generated Cards" (:name result)))) - (testing "should create cards with correct properties" (let [created-cards (t2/select :model/Card :document_id (:id result)) card1 (first (filter #(= "Generated Card 1" (:name %)) created-cards)) card2 (first (filter #(= "Generated Card 2" (:name %)) created-cards))] - (testing "should have created exactly 2 cards" (is (= 2 (count created-cards)))) - (testing "card1 inherits document's collection_id" (is (some? card1)) (is (= "Generated Card 1" (:name card1))) (is (= :question (:type card1))) (is (= (:id result) (:document_id card1))) (is (= col-id (:collection_id card1)))) - (testing "card2 uses explicit collection_id" (is (some? card2)) (is (= "Generated Card 2" (:name card2))) (is (= :question (:type card2))) (is (= (:id result) (:document_id card2))) (is (= col-id (:collection_id card2)))) - (testing "should update the doc with the substituted card ids" (let [[card1-embed card2-embed] (get-in result [:document :content])] (is (= (:id card1) (get-in card1-embed [:attrs :id]))) (is (= (:id card2) (get-in card2-embed [:attrs :id]))))))) - (testing "document should have correct properties" (let [document (t2/select-one :model/Document :id (:id result))] (is (= "Document with Generated Cards" (:name document))) @@ -452,41 +426,34 @@ {:type "paragraph"}]} :collection_id col-id :cards cards-to-create})] - (testing "should update document successfully" (is (= document-id (:id result))) (is (= "Updated Document with Generated Cards" (:name result))) (is (= col-id (:collection_id result)))) - (testing "should create cards with correct properties" (let [created-cards (t2/select :model/Card :document_id document-id) card1 (first (filter #(= "Updated Generated Card 1" (:name %)) created-cards)) card2 (first (filter #(= "Updated Generated Card 2" (:name %)) created-cards))] - (testing "should have created exactly 2 cards" (is (= 2 (count created-cards)))) - (testing "card1 inherits document's updated collection_id" (is (some? card1)) (is (= "Updated Generated Card 1" (:name card1))) (is (= :question (:type card1))) (is (= document-id (:document_id card1))) (is (= col-id (:collection_id card1)))) - (testing "card2 uses explicit collection_id" (is (some? card2)) (is (= "Updated Generated Card 2" (:name card2))) (is (= :question (:type card2))) (is (= document-id (:document_id card2))) (is (= col-id (:collection_id card2)))) - (testing "should update the doc with the substituted card ids" (let [[card1-embed card2-embed] (get-in result [:document :content])] (is (= (:id card1) (get-in card1-embed [:attrs :id]))) (is (= (:id card2) (get-in card2-embed [:attrs :id]))))))) - (testing "document should have updated properties" (let [document (t2/select-one :model/Document :id document-id)] (is (= "Updated Document with Generated Cards" (:name document))) @@ -505,7 +472,6 @@ :dataset_query (mt/mbql-query venues) :display :table :visualization_settings {}}}})) - (testing "should reject missing required card fields" (mt/user-http-request :crowberto :post 400 "document/" @@ -533,7 +499,6 @@ {:name "Document That Should Rollback" :document (documents.test-util/text->prose-mirror-ast "Doc that should rollback") :cards invalid-cards}) - ;; Verify no document was created (is (zero? (t2/count :model/Document :name "Document That Should Rollback"))))))) @@ -547,13 +512,11 @@ :dataset_query {:type :invalid-type} ; invalid query :display :table :visualization_settings {}}}] - (mt/user-http-request :crowberto :put 403 (format "document/%s" document-id) {:name "Document That Should Rollback" :document (documents.test-util/text->prose-mirror-ast "Doc that should rollback") :cards invalid-cards}) - ;; Verify document wasn't updated (let [unchanged-document (t2/select-one :model/Document :id document-id)] (is (= (:name initial-document) (:name unchanged-document))) @@ -576,7 +539,6 @@ :cards cards-to-create}) created-cards (t2/select :model/Card :document_id (:id result)) card (first created-cards)] - (is (= 1 (count created-cards))) (is (nil? (:collection_id card))) ; should inherit nil from document (is (= (:id result) (:document_id card)))))))) @@ -603,20 +565,16 @@ :cards cards-to-create}) created-cards (t2/select :model/Card :document_id (:id result)) card (first created-cards)] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Model Card" (:name result)))) - (testing "should normalize card type from :model to :question" (is (= 1 (count created-cards))) (is (= "Model Card" (:name card))) (is (= :question (:type card))) (is (not= :model (:type card)))) - (testing "should remove dashboard_id" (is (nil? (:dashboard_id card)))) - (testing "should preserve other card properties" (is (= (:id result) (:document_id card))) (is (= col-id (:collection_id card))) @@ -646,20 +604,16 @@ :cards cards-to-create}) created-cards (t2/select :model/Card :document_id document-id) card (first created-cards)] - (testing "should update document successfully" (is (= document-id (:id result))) (is (= "Updated Document with Model Card" (:name result)))) - (testing "should normalize card type from :model to :question" (is (= 1 (count created-cards))) (is (= "Updated Model Card" (:name card))) (is (= :question (:type card))) (is (not= :model (:type card)))) - (testing "should remove dashboard_id" (is (nil? (:dashboard_id card)))) - (testing "should preserve other card properties" (is (= document-id (:document_id card))) (is (= col-id (:collection_id card))) @@ -696,16 +650,13 @@ created-cards (t2/select :model/Card :document_id (:id result)) model-card (first (filter #(= "Model Card" (:name %)) created-cards)) question-card (first (filter #(= "Question Card" (:name %)) created-cards))] - (testing "should create both cards" (is (= 2 (count created-cards))) (is (some? model-card)) (is (some? question-card))) - (testing "model card should be normalized to question type" (is (= :question (:type model-card))) (is (nil? (:dashboard_id model-card)))) - (testing "question card should remain unchanged" (is (= :question (:type question-card))) (is (nil? (:dashboard_id question-card))))))))) @@ -736,38 +687,30 @@ :name nil}} {:type "paragraph"}]} :collection_id col-id})] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Cloned Cards" (:name result)))) - (testing "should clone cards with correct properties" (let [cloned-cards (t2/select :model/Card :document_id (:id result)) cloned-card-1 (first (filter #(= "Existing Card 1" (:name %)) cloned-cards)) cloned-card-2 (first (filter #(= "Existing Card 2" (:name %)) cloned-cards))] - (testing "should have created exactly 2 cloned cards" (is (= 2 (count cloned-cards)))) - (testing "cloned cards should not be the same as originals" (is (not= existing-card-1 (:id cloned-card-1))) (is (not= existing-card-2 (:id cloned-card-2)))) - (testing "cloned cards should have document_id set" (is (= (:id result) (:document_id cloned-card-1))) (is (= (:id result) (:document_id cloned-card-2)))) - (testing "cloned cards should inherit document's collection_id" (is (= col-id (:collection_id cloned-card-1))) (is (= col-id (:collection_id cloned-card-2)))) - (testing "should update the AST with cloned card IDs" (let [[card1-embed card2-embed] (get-in result [:document :content])] (is (= (:id cloned-card-1) (get-in card1-embed [:attrs :id]))) (is (= (:id cloned-card-2) (get-in card2-embed [:attrs :id]))))))) - (testing "original cards should remain unchanged" (let [original-1 (t2/select-one :model/Card :id existing-card-1) original-2 (t2/select-one :model/Card :id existing-card-2)] @@ -800,28 +743,22 @@ :dataset_query (mt/mbql-query users) :display :scalar :visualization_settings {}}}})] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Mixed Cards" (:name result)))) - (testing "should handle both cloned and new cards" (let [all-cards (t2/select :model/Card :document_id (:id result)) cloned-card (first (filter #(= "Existing Card" (:name %)) all-cards)) new-card (first (filter #(= "New Card" (:name %)) all-cards))] - (testing "should have created exactly 2 cards total" (is (= 2 (count all-cards)))) - (testing "cloned card should be different from original" (is (not= existing-card (:id cloned-card))) (is (= (:id result) (:document_id cloned-card)))) - (testing "new card should be created properly" (is (some? new-card)) (is (= "New Card" (:name new-card))) (is (= (:id result) (:document_id new-card)))) - (testing "should update the AST with both cloned and new card IDs" (let [[cloned-embed new-embed] (get-in result [:document :content])] (is (= (:id cloned-card) @@ -859,23 +796,18 @@ :name nil}} {:type "paragraph"}]} :collection_id col-id})] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Mixed Association Cards" (:name result)))) - (testing "should clone cards" (let [cloned-cards (t2/select :model/Card :document_id (:id result))] - (testing "should have cloned only 1 card" (is (= 2 (count cloned-cards)))) - (testing "should update AST correctly" (let [associated-ids (set (keep #(get-in % [:attrs :id]) (get-in result [:document :content])))] (is (= #{(:id (first cloned-cards)) (:id (second cloned-cards))} associated-ids)))))) - (testing "original associated card should remain with its document" (let [original-associated (t2/select-one :model/Card :id associated-card)] (is (= other-doc-id (:document_id original-associated)))))))))) @@ -897,7 +829,7 @@ :dataset_query (mt/mbql-query users) :display :bar :visualization_settings {}}] - ;; Update the document with both cards + ;; Update the document with both cards (let [result (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) {:document {:type "doc" @@ -908,25 +840,19 @@ :attrs {:id card-without-doc :name nil}} {:type "paragraph"}]}})] - (testing "should update document successfully" (is (= document-id (:id result)))) - (testing "should only clone card not already in document" (let [cards-in-doc (t2/select :model/Card :document_id document-id) card-already-in-doc (first (filter #(= "Card Already in Document" (:name %)) cards-in-doc)) cloned-card (first (filter #(= "Card Without Document" (:name %)) cards-in-doc))] - (testing "should have exactly 2 cards - 1 original and 1 cloned" (is (= 2 (count cards-in-doc)))) - (testing "card already in document should not be cloned" (is (= existing-card-in-doc (:id card-already-in-doc)))) - (testing "card without document should be cloned" (is (not= card-without-doc (:id cloned-card))) (is (= "Card Without Document" (:name cloned-card)))) - (testing "should update AST correctly" (let [associated-ids (set (keep #(get-in % [:attrs :id]) (get-in result [:document :content])))] (is (= #{existing-card-in-doc @@ -960,34 +886,27 @@ :attrs {:id existing-card-2 :name nil}} {:type "paragraph"}]}})] - (testing "should update document successfully" (is (= document-id (:id result))) (is (= "Updated Document with Cloned Cards" (:name result)))) - (testing "should clone cards with correct properties" (let [cloned-cards (t2/select :model/Card :document_id document-id) cloned-card-1 (first (filter #(= "Existing Card 1" (:name %)) cloned-cards)) cloned-card-2 (first (filter #(= "Existing Card 2" (:name %)) cloned-cards))] - (testing "should have created exactly 2 cloned cards" (is (= 2 (count cloned-cards)))) - (testing "cloned cards should not be the same as originals" (is (not= existing-card-1 (:id cloned-card-1))) (is (not= existing-card-2 (:id cloned-card-2)))) - (testing "cloned cards should have document_id set" (is (= document-id (:document_id cloned-card-1))) (is (= document-id (:document_id cloned-card-2)))) - (testing "should update the AST with cloned card IDs" (let [[card1-embed card2-embed] (get-in result [:document :content])] (is (= (:id cloned-card-1) (get-in card1-embed [:attrs :id]))) (is (= (:id cloned-card-2) (get-in card2-embed [:attrs :id]))))))) - (testing "original cards should remain unchanged" (let [original-1 (t2/select-one :model/Card :id existing-card-1) original-2 (t2/select-one :model/Card :id existing-card-2)] @@ -1021,28 +940,22 @@ :dataset_query (mt/mbql-query users) :display :scalar :visualization_settings {}}}})] - (testing "should update document successfully" (is (= document-id (:id result))) (is (= "Updated Document with Mixed Cards" (:name result)))) - (testing "should handle both cloned and new cards" (let [all-cards (t2/select :model/Card :document_id document-id) cloned-card (first (filter #(= "Existing Card" (:name %)) all-cards)) new-card (first (filter #(= "New Card" (:name %)) all-cards))] - (testing "should have created exactly 2 cards total" (is (= 2 (count all-cards)))) - (testing "cloned card should be different from original" (is (not= existing-card (:id cloned-card))) (is (= document-id (:document_id cloned-card)))) - (testing "new card should be created properly" (is (some? new-card)) (is (= "New Card" (:name new-card))) (is (= document-id (:document_id new-card)))) - (testing "should update the AST with both cloned and new card IDs" (let [[cloned-embed new-embed] (get-in result [:document :content])] (is (= (:id cloned-card) @@ -1104,19 +1017,15 @@ :name nil}}]}]} {:type "paragraph"}]} :collection_id col-id})] - (testing "should create document successfully" (is (pos? (:id result))) (is (= "Document with Nested Cards" (:name result)))) - (testing "should clone cards in nested structures" (let [cloned-cards (t2/select :model/Card :document_id (:id result)) cloned-card-1 (first (filter #(= "Card 1" (:name %)) cloned-cards)) cloned-card-2 (first (filter #(= "Card 2" (:name %)) cloned-cards))] - (testing "should have created exactly 2 cloned cards" (is (= 2 (count cloned-cards)))) - (testing "should update nested AST with cloned card IDs" (let [bullet-list (first (get-in result [:document :content])) [list-item-1 list-item-2] (:content bullet-list) @@ -1157,7 +1066,6 @@ :collection_id col-id}) cloned-cards (t2/select :model/Card :document_id (:id result)) cloned-card (first cloned-cards)] - (testing "cloned card preserves all metadata" (is (= "Complex Card" (:name cloned-card))) (is (= (:dataset_query (t2/select-one :model/Card :id original-card)) @@ -1174,7 +1082,6 @@ :name "Category" :slug "category"}] (:parameters cloned-card)))) - (testing "cloned card has new ID and document association" (is (not= original-card (:id cloned-card))) (is (= (:id result) (:document_id cloned-card))) @@ -1191,7 +1098,7 @@ :dataset_query (mt/mbql-query venues) :display :table :visualization_settings {}}] - ;; First update - should clone the card + ;; First update - should clone the card (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) {:document {:type "doc" @@ -1200,11 +1107,9 @@ :name nil}}]}}) (let [first-cloned-cards (t2/select :model/Card :document_id document-id) first-cloned-id (:id (first first-cloned-cards))] - (testing "first update creates one clone" (is (= 1 (count first-cloned-cards)))) - - ;; Second update with the already-cloned card ID - should NOT create another clone + ;; Second update with the already-cloned card ID - should NOT create another clone (let [second-result (mt/user-http-request :crowberto :put 200 (format "document/%s" document-id) {:document {:type "doc" @@ -1212,11 +1117,9 @@ :attrs {:id first-cloned-id :name nil}}]}}) second-cloned-cards (t2/select :model/Card :document_id document-id)] - (testing "second update doesn't create additional clones" (is (= 1 (count second-cloned-cards))) (is (= first-cloned-id (:id (first second-cloned-cards)))) - (testing "document AST remains with the same card ID" (let [card-embed (first (get-in second-result [:document :content]))] (is (= first-cloned-id (get-in card-embed [:attrs :id]))))))))))) @@ -1227,14 +1130,13 @@ (mt/with-temp [:model/Collection {read-only-col :id} {:name "Read Only Collection"} :model/Collection {write-col :id} {:name "Write Collection"} :model/Collection {no-access-col :id} {:name "No Access Collection"}] - - ;; Set up permissions for :rasta user + ;; Set up permissions for :rasta user (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] - ;; Grant read-only access to read-only-col + ;; Grant read-only access to read-only-col (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write-col + ;; Grant write access to write-col (perms/grant-collection-readwrite-permissions! group write-col) - ;; No permissions for no-access-col (implicitly) + ;; No permissions for no-access-col (implicitly) (testing "POST /api/document/ - :rasta can create documents in collections with write access" (mt/with-model-cleanup [:model/Document] @@ -1246,14 +1148,12 @@ (is (pos? (:id result))) (is (= "Rasta's Document" (:name result))) (is (= write-col (:collection_id result)))))) - (testing "POST /api/document/ - :rasta cannot create documents in read-only collections" (mt/user-http-request :rasta :post 403 "document/" {:name "Should Fail" :document (documents.test-util/text->prose-mirror-ast "Should not be created") :collection_id read-only-col})) - (testing "POST /api/document/ - :rasta cannot create documents in no-access collections" (mt/user-http-request :rasta :post 403 "document/" @@ -1267,14 +1167,13 @@ (mt/with-temp [:model/Collection {read-only-col :id} {:name "Read Only Collection"} :model/Collection {write-col :id} {:name "Write Collection"} :model/Collection {no-access-col :id} {:name "No Access Collection"}] - - ;; Set up permissions for :rasta user + ;; Set up permissions for :rasta user (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] - ;; Grant read-only access to read-only-col + ;; Grant read-only access to read-only-col (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write-col + ;; Grant write access to write-col (perms/grant-collection-readwrite-permissions! group write-col) - ;; No permissions for no-access-col (implicitly) + ;; No permissions for no-access-col (implicitly) (testing "PUT /api/document/:id - :rasta can update documents in collections with write access" (mt/with-temp [:model/Document {doc-id :id} {:name "Original Document" @@ -1287,7 +1186,6 @@ (is (= doc-id (:id result))) (is (= "Updated by Rasta" (:name result))) (is (= (documents.test-util/text->prose-mirror-ast "Updated content") (:document result)))))) - (testing "PUT /api/document/:id - :rasta cannot update documents in read-only collections" (mt/with-temp [:model/Document {doc-id :id} {:name "Read Only Document" :document (documents.test-util/text->prose-mirror-ast "Read only content") @@ -1295,7 +1193,6 @@ (mt/user-http-request :rasta :put 403 (format "document/%s" doc-id) {:name "Should not update"}))) - (testing "PUT /api/document/:id - :rasta cannot update documents in no-access collections" (mt/with-temp [:model/Document {doc-id :id} {:name "No Access Document" :document (documents.test-util/text->prose-mirror-ast "No access content") @@ -1310,16 +1207,14 @@ (mt/with-temp [:model/Collection {read-only-col :id} {:name "Read Only Collection"} :model/Collection {write-col :id} {:name "Write Collection"} :model/Collection {destination-col :id} {:name "Destination Collection"}] - - ;; Set up permissions for :rasta user + ;; Set up permissions for :rasta user (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] - ;; Grant read-only access to read-only-col + ;; Grant read-only access to read-only-col (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write-col + ;; Grant write access to write-col (perms/grant-collection-readwrite-permissions! group write-col) - ;; Grant write access to destination-col + ;; Grant write access to destination-col (perms/grant-collection-readwrite-permissions! group destination-col) - (testing "PUT /api/document/:id - :rasta can move documents between collections with write access to both" (mt/with-temp [:model/Document {doc-id :id} {:name "Document to Move" :document (documents.test-util/text->prose-mirror-ast "Moving document") @@ -1329,9 +1224,8 @@ {:collection_id destination-col})] (is (= doc-id (:id result))) (is (= destination-col (:collection_id result))) - ;; Verify document was actually moved + ;; Verify document was actually moved (is (= destination-col (:collection_id (t2/select-one :model/Document :id doc-id))))))) - (testing "PUT /api/document/:id - :rasta cannot move documents from collections without write access" (mt/with-temp [:model/Document {doc-id :id} {:name "Cannot Move From Here" :document (documents.test-util/text->prose-mirror-ast "No permission to move") @@ -1339,9 +1233,8 @@ (mt/user-http-request :rasta :put 403 (format "document/%s" doc-id) {:collection_id destination-col}) - ;; Verify document wasn't moved + ;; Verify document wasn't moved (is (= read-only-col (:collection_id (t2/select-one :model/Document :id doc-id)))))) - (testing "PUT /api/document/:id - :rasta cannot move documents to collections without write access" (mt/with-temp [:model/Document {doc-id :id} {:name "Cannot Move To There" :document (documents.test-util/text->prose-mirror-ast "No permission for destination") @@ -1349,7 +1242,7 @@ (mt/user-http-request :rasta :put 403 (format "document/%s" doc-id) {:collection_id read-only-col}) - ;; Verify document wasn't moved + ;; Verify document wasn't moved (is (= write-col (:collection_id (t2/select-one :model/Document :id doc-id))))))))))) (deftest rasta-document-read-permissions-test @@ -1358,14 +1251,13 @@ (mt/with-temp [:model/Collection {read-only-col :id} {:name "Read Only Collection"} :model/Collection {write-col :id} {:name "Write Collection"} :model/Collection {no-access-col :id} {:name "No Access Collection"}] - - ;; Set up permissions for :rasta user + ;; Set up permissions for :rasta user (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] - ;; Grant read-only access to read-only-col + ;; Grant read-only access to read-only-col (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write-col + ;; Grant write access to write-col (perms/grant-collection-readwrite-permissions! group write-col) - ;; No permissions for no-access-col (implicitly) + ;; No permissions for no-access-col (implicitly) (testing "GET /api/document/:id - :rasta can read documents from collections with write access" (mt/with-temp [:model/Document {doc-id :id} {:name "Write Access Document" @@ -1377,7 +1269,6 @@ (is (= (documents.test-util/text->prose-mirror-ast "Can read with write access") (:document result))) (testing "includes can_write=true for collections with write access" (is (true? (get result :can_write))))))) - (testing "GET /api/document/:id - :rasta can read documents from collections with read access" (mt/with-temp [:model/Document {doc-id :id} {:name "Read Access Document" :document (documents.test-util/text->prose-mirror-ast "Can read with read access") @@ -1388,7 +1279,6 @@ (is (= (documents.test-util/text->prose-mirror-ast "Can read with read access") (:document result))) (testing "includes can_write=false for collections with only read access" (is (false? (get result :can_write))))))) - (testing "GET /api/document/:id - :rasta cannot read documents from collections without access" (mt/with-temp [:model/Document {doc-id :id} {:name "No Access Document" :document (documents.test-util/text->prose-mirror-ast "Cannot read this") @@ -1402,14 +1292,13 @@ (mt/with-temp [:model/Collection {read-only-col :id} {:name "Read Only Collection"} :model/Collection {write-col :id} {:name "Write Collection"} :model/Collection {no-access-col :id} {:name "No Access Collection"}] - - ;; Set up permissions for :rasta user + ;; Set up permissions for :rasta user (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] - ;; Grant read-only access to read-only-col + ;; Grant read-only access to read-only-col (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write-col + ;; Grant write access to write-col (perms/grant-collection-readwrite-permissions! group write-col) - ;; No permissions for no-access-col (implicitly) + ;; No permissions for no-access-col (implicitly) (testing "GET /api/document - :rasta only sees documents from accessible collections" (mt/with-temp [:model/Document _ {:name "Doc in Write Collection" @@ -1447,21 +1336,17 @@ :put 200 (format "document/%s" doc-id) {:archived true})] (is (true? (:archived result))) - - ;; Verify document is actually archived in database + ;; Verify document is actually archived in database (is (true? (:archived (t2/select-one :model/Document :id doc-id)))))) - (testing "archived document doesn't appear in normal listings" (let [documents (mt/user-http-request :crowberto :get 200 "document/")] (is (not (some #(= doc-id (:id %)) (:items documents)))))) - (testing "can unarchive document with archived=false" (let [result (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false})] (is (false? (:archived result))) - - ;; Verify document is actually unarchived in database + ;; Verify document is actually unarchived in database (is (false? (:archived (t2/select-one :model/Document :id doc-id))))))))) (deftest document-archive-with-cards-test @@ -1481,35 +1366,27 @@ :model/Card {other-card-id :id} {:name "Other Card" :collection_id coll-id :dataset_query (mt/mbql-query venues)}] - (testing "archiving document archives associated cards" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true}) - - ;; Verify document is archived + ;; Verify document is archived (is (true? (:archived (t2/select-one :model/Document :id doc-id)))) - - ;; Verify associated cards are archived + ;; Verify associated cards are archived (is (true? (:archived (t2/select-one :model/Card :id card1-id)))) (is (true? (:archived (t2/select-one :model/Card :id card2-id)))) - - ;; Verify non-associated card is NOT archived + ;; Verify non-associated card is NOT archived (is (false? (:archived (t2/select-one :model/Card :id other-card-id))))) - (testing "unarchiving document unarchives associated cards" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false}) - - ;; Verify document is unarchived + ;; Verify document is unarchived (is (false? (:archived (t2/select-one :model/Document :id doc-id)))) - - ;; Verify associated cards are unarchived + ;; Verify associated cards are unarchived (is (false? (:archived (t2/select-one :model/Card :id card1-id)))) (is (false? (:archived (t2/select-one :model/Card :id card2-id)))) - - ;; Verify other card remains unchanged + ;; Verify other card remains unchanged (is (false? (:archived (t2/select-one :model/Card :id other-card-id)))))))) (deftest document-archive-permissions-test @@ -1523,27 +1400,22 @@ :model/Document {write-doc-id :id} {:name "Write Document" :document (documents.test-util/text->prose-mirror-ast "Writable") :collection_id write-col}] - (mt/with-group-for-user [group :rasta] - ;; Grant read-only access to read-only collection + ;; Grant read-only access to read-only collection (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write collection + ;; Grant write access to write collection (perms/grant-collection-readwrite-permissions! group write-col) - (testing "user with write permissions can archive document" (let [result (mt/user-http-request :rasta :put 200 (format "document/%s" write-doc-id) {:archived true})] (is (true? (:archived result))))) - (testing "user without write permissions cannot archive document" (mt/user-http-request :rasta :put 403 (format "document/%s" read-only-doc-id) {:archived true}) - - ;; Verify document wasn't archived + ;; Verify document wasn't archived (is (false? (:archived (t2/select-one :model/Document :id read-only-doc-id))))) - (testing "user with write permissions can unarchive document" (let [result (mt/user-http-request :rasta :put 200 (format "document/%s" write-doc-id) @@ -1570,42 +1442,34 @@ :model/Card {standalone-card-id :id} {:name "Standalone Card" :collection_id coll-id :dataset_query (mt/mbql-query venues)}] - (testing "archiving collection archives documents and all cards" (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) {:archived true}) - - ;; Verify collection is archived + ;; Verify collection is archived (is (true? (:archived (t2/select-one :model/Collection :id coll-id)))) - - ;; Verify documents are archived (not directly) + ;; Verify documents are archived (not directly) (is (true? (:archived (t2/select-one :model/Document :id doc1-id)))) (is (false? (:archived_directly (t2/select-one :model/Document :id doc1-id)))) (is (true? (:archived (t2/select-one :model/Document :id doc2-id)))) (is (false? (:archived_directly (t2/select-one :model/Document :id doc2-id)))) - - ;; Verify all cards are archived (not directly) + ;; Verify all cards are archived (not directly) (is (true? (:archived (t2/select-one :model/Card :id card1-id)))) (is (false? (:archived_directly (t2/select-one :model/Card :id card1-id)))) (is (true? (:archived (t2/select-one :model/Card :id card2-id)))) (is (false? (:archived_directly (t2/select-one :model/Card :id card2-id)))) (is (true? (:archived (t2/select-one :model/Card :id standalone-card-id)))) (is (false? (:archived_directly (t2/select-one :model/Card :id standalone-card-id))))) - (testing "unarchiving collection restores documents and cards" (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) {:archived false}) - - ;; Verify collection is unarchived + ;; Verify collection is unarchived (is (false? (:archived (t2/select-one :model/Collection :id coll-id)))) - - ;; Verify documents are unarchived + ;; Verify documents are unarchived (is (false? (:archived (t2/select-one :model/Document :id doc1-id)))) (is (false? (:archived (t2/select-one :model/Document :id doc2-id)))) - - ;; Verify cards are unarchived + ;; Verify cards are unarchived (is (false? (:archived (t2/select-one :model/Card :id card1-id)))) (is (false? (:archived (t2/select-one :model/Card :id card2-id)))) (is (false? (:archived (t2/select-one :model/Card :id standalone-card-id)))))))) @@ -1620,59 +1484,50 @@ :document_id doc-id :collection_id coll-id :dataset_query (mt/mbql-query venues)}] - (testing "directly archiving document sets archived_directly=true" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true}) - (let [doc (t2/select-one :model/Document :id doc-id) card (t2/select-one :model/Card :id card-id)] (is (true? (:archived doc))) (is (true? (:archived_directly doc))) (is (true? (:archived card))) (is (true? (:archived_directly card))))) - (testing "unarchiving directly archived document works" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false}) - (let [doc (t2/select-one :model/Document :id doc-id) card (t2/select-one :model/Card :id card-id)] (is (false? (:archived doc))) (is (false? (:archived_directly doc))) (is (false? (:archived card))) (is (false? (:archived_directly card))))) - - ;; Archive via collection to test indirect archiving + ;; Archive via collection to test indirect archiving (testing "indirectly archiving via collection sets archived_directly=false" (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) {:archived true}) - (let [doc (t2/select-one :model/Document :id doc-id) card (t2/select-one :model/Card :id card-id)] (is (true? (:archived doc))) (is (false? (:archived_directly doc))) (is (true? (:archived card))) (is (false? (:archived_directly card))))) - (testing "directly archived documents stay archived when collection is unarchived" - ;; First, directly archive the document + ;; First, directly archive the document (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false}) (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true}) - - ;; Then unarchive the collection + ;; Then unarchive the collection (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) {:archived false}) - - ;; Document should remain archived because it was archived directly + ;; Document should remain archived because it was archived directly (let [doc (t2/select-one :model/Document :id doc-id) card (t2/select-one :model/Card :id card-id)] (is (true? (:archived doc))) @@ -1690,20 +1545,16 @@ :document (documents.test-util/text->prose-mirror-ast "Archived") :collection_id coll-id :archived true}] - (testing "GET /api/document/ excludes archived documents" (let [documents (mt/user-http-request :crowberto :get 200 "document/") document-names (set (map :name (:items documents)))] (is (contains? document-names "Active Document")) (is (not (contains? document-names "Archived Document"))))) - (testing "GET /api/document/:id returns 200 for archived documents" - ;; Active document should be accessible + ;; Active document should be accessible (mt/user-http-request :crowberto :get 200 (format "document/%s" active-doc-id)) - - ;; Archived document should return 404 + ;; Archived document should return 404 (mt/user-http-request :crowberto :get 200 (format "document/%s" archived-doc-id))) - (testing "Collection items endpoint excludes archived documents" (let [items (mt/user-http-request :crowberto :get 200 (format "collection/%s/items" coll-id)) item-names (set (map :name (:data items)))] @@ -1714,7 +1565,6 @@ (testing "Document archiving publishes appropriate events" (mt/with-temp [:model/Document {doc-id :id} {:name "Event Test Document" :document (documents.test-util/text->prose-mirror-ast "Event test")}] - (testing "archiving document publishes archive event" (mt/with-model-cleanup [:model/Document] (let [events (atom [])] @@ -1723,10 +1573,8 @@ (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true}) - - ;; Should have published document-archive event + ;; Should have published document-archive event (is (some #(= :event/document-delete (:topic %)) @events)))))) - (testing "unarchiving document publishes update event" (mt/with-model-cleanup [:model/Document] (let [events (atom [])] @@ -1735,8 +1583,7 @@ (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived false}) - - ;; Should have published document-update event (not archive event) + ;; Should have published document-update event (not archive event) (is (some #(= :event/document-update (:topic %)) @events)) (is (not (some #(= :event/document-delete (:topic %)) @events)))))))))) @@ -1747,8 +1594,7 @@ :model/Card {card-id :id} {:name "Associated Card" :document_id doc-id :dataset_query (mt/mbql-query venues)}] - - ;; Simulate a failure during card archiving + ;; Simulate a failure during card archiving (testing "failure during card archiving rolls back document archiving" (with-redefs [t2/update! (fn [model id updates] (if (and (= model :model/Card) (:archived updates)) @@ -1757,8 +1603,7 @@ (mt/user-http-request :crowberto :put 500 (format "document/%s" doc-id) {:archived true}) - - ;; Verify document wasn't archived due to rollback + ;; Verify document wasn't archived due to rollback (is (false? (:archived (t2/select-one :model/Document :id doc-id)))) (is (false? (:archived (t2/select-one :model/Card :id card-id))))))))) @@ -1778,7 +1623,6 @@ :model/Document {active-doc :id} {:name "Active Document" :document (documents.test-util/text->prose-mirror-ast "Active") :collection_id coll-id}] - (testing "unarchiving collection only restores collection-archived documents" (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) @@ -1786,21 +1630,17 @@ (mt/user-http-request :crowberto :put 200 (format "collection/%s" coll-id) {:archived false}) - - ;; Directly archived document should remain archived + ;; Directly archived document should remain archived (is (true? (:archived (t2/select-one :model/Document :id directly-archived-doc)))) (is (true? (:archived_directly (t2/select-one :model/Document :id directly-archived-doc)))) - - ;; Collection archived document should be unarchived + ;; Collection archived document should be unarchived (is (false? (:archived (t2/select-one :model/Document :id collection-archived-doc)))) (is (false? (:archived_directly (t2/select-one :model/Document :id collection-archived-doc)))) - - ;; Active document should remain active + ;; Active document should remain active (is (false? (:archived (t2/select-one :model/Document :id active-doc)))))))) (deftest document-archive-edge-cases-test (testing "Document archiving edge cases" - (testing "archiving already archived document is idempotent" (mt/with-temp [:model/Document {doc-id :id} {:name "Already Archived" :document (documents.test-util/text->prose-mirror-ast "Already archived") @@ -1811,7 +1651,6 @@ {:archived true})] (is (true? (:archived result))) (is (true? (:archived_directly (t2/select-one :model/Document :id doc-id))))))) - (testing "unarchiving already active document is idempotent" (mt/with-temp [:model/Document {doc-id :id} {:name "Already Active" :document (documents.test-util/text->prose-mirror-ast "Already active")}] @@ -1819,18 +1658,16 @@ :put 200 (format "document/%s" doc-id) {:archived false})] (is (false? (:archived result)))))) - (testing "archiving document in trash collection" (let [trash-collection-id (collection/trash-collection-id)] (mt/with-temp [:model/Document {doc-id :id} {:name "Document in Trash" :document (documents.test-util/text->prose-mirror-ast "In trash") :collection_id trash-collection-id}] - ;; Should be able to archive document in trash + ;; Should be able to archive document in trash (let [result (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:archived true})] (is (true? (:archived result))))))) - (testing "document with no associated cards" (mt/with-temp [:model/Document {doc-id :id} {:name "No Cards Document" :document (documents.test-util/text->prose-mirror-ast "No cards")}] @@ -1838,9 +1675,8 @@ :put 200 (format "document/%s" doc-id) {:archived true})] (is (true? (:archived result))) - ;; Should not fail even with no associated cards + ;; Should not fail even with no associated cards (is (zero? (t2/count :model/Card :document_id doc-id)))))) - (testing "archiving and updating other fields simultaneously" (mt/with-temp [:model/Document {doc-id :id} {:name "Original Name" :document (documents.test-util/text->prose-mirror-ast "Original")}] @@ -1860,10 +1696,8 @@ :archived true}] (testing "can delete archived document" (mt/user-http-request :crowberto :delete (format "document/%s" doc-id)) - - ;; Verify document is actually deleted from database + ;; Verify document is actually deleted from database (is (nil? (t2/select-one :model/Document :id doc-id)))) - (testing "cannot delete same document twice" (mt/user-http-request :crowberto :delete 404 (format "document/%s" doc-id)))))) @@ -1874,8 +1708,7 @@ :archived false}] (testing "returns 400 error when trying to delete non-archived document" (mt/user-http-request :crowberto :delete 400 (format "document/%s" doc-id)) - - ;; Verify document still exists + ;; Verify document still exists (is (some? (t2/select-one :model/Document :id doc-id))))))) (deftest delete-document-permissions-test @@ -1891,23 +1724,18 @@ :document (documents.test-util/text->prose-mirror-ast "Writable") :collection_id write-col :archived true}] - (mt/with-group-for-user [group :rasta] - ;; Grant read-only access to read-only collection + ;; Grant read-only access to read-only collection (perms/grant-collection-read-permissions! group read-only-col) - ;; Grant write access to write collection + ;; Grant write access to write collection (perms/grant-collection-readwrite-permissions! group write-col) - (testing "user with write permissions can delete archived document" (mt/user-http-request :rasta :delete (format "document/%s" write-doc-id)) - - ;; Verify document is deleted + ;; Verify document is deleted (is (nil? (t2/select-one :model/Document :id write-doc-id)))) - (testing "user without write permissions cannot delete archived document" (mt/user-http-request :rasta :delete 403 (format "document/%s" read-only-doc-id)) - - ;; Verify document still exists + ;; Verify document still exists (is (some? (t2/select-one :model/Document :id read-only-doc-id))))))))) (deftest delete-document-with-cards-test @@ -1930,18 +1758,14 @@ :model/Card {other-card-id :id} {:name "Other Card" :collection_id coll-id :dataset_query (mt/mbql-query venues)}] - (testing "deleting document also deletes associated cards via cascade" (mt/user-http-request :crowberto :delete (format "document/%s" doc-id)) - - ;; Verify document is deleted + ;; Verify document is deleted (is (nil? (t2/select-one :model/Document :id doc-id))) - - ;; Verify associated cards are deleted (assuming CASCADE DELETE in schema) + ;; Verify associated cards are deleted (assuming CASCADE DELETE in schema) (is (nil? (t2/select-one :model/Card :id card1-id))) (is (nil? (t2/select-one :model/Card :id card2-id))) - - ;; Verify non-associated card still exists + ;; Verify non-associated card still exists (is (some? (t2/select-one :model/Card :id other-card-id))))))) (deftest delete-document-nonexistent-test @@ -1957,8 +1781,7 @@ (with-redefs [events/publish-event! (fn [topic event] (swap! events conj {:topic topic :event event}))] (mt/user-http-request :crowberto :delete 204 (format "document/%s" doc-id)) - - ;; Should have published document-delete event + ;; Should have published document-delete event (is (some #(= :event/document-delete (:topic %)) @events)) (let [delete-event (first (filter #(= :event/document-delete (:topic %)) @events))] (is (= "Event Test Document" (get-in delete-event [:event :object :name]))) @@ -1975,7 +1798,6 @@ :collection_id collection-id :collection_position 3}) existing-doc-id (:id existing-doc-result)] - (testing "inserting document at existing position shifts others" ;; Insert new document at position 3 via API - should shift existing document (let [new-doc-result (mt/user-http-request :crowberto @@ -1986,11 +1808,9 @@ :collection_position 3})] ;; New document should have position 3 (is (= 3 (:collection_position new-doc-result))) - ;; Existing document should be shifted to position 4 (let [shifted-document (t2/select-one :model/Document :id existing-doc-id)] (is (= 4 (:collection_position shifted-document)))))) - (testing "inserting document without position works" (let [no-position-result (mt/user-http-request :crowberto :post 200 "document/" @@ -2024,7 +1844,6 @@ doc1-id (:id doc1-result) doc2-id (:id doc2-result) doc3-id (:id doc3-result)] - (testing "moving document to different position reconciles others" ;; Move document 3 to position 1 via API - should shift others (let [updated-doc3 (mt/user-http-request :crowberto @@ -2032,14 +1851,12 @@ {:collection_position 1})] ;; Document 3 should now be at position 1 (is (= 1 (:collection_position updated-doc3))) - ;; Check that other documents were shifted (let [doc1 (t2/select-one :model/Document :id doc1-id) doc2 (t2/select-one :model/Document :id doc2-id)] ;; Original documents should be shifted (is (= 2 (:collection_position doc1))) (is (= 3 (:collection_position doc2)))))) - (testing "moving document to different collection reconciles both" (mt/with-temp [:model/Collection {other-collection-id :id} {:name "Other Collection"}] ;; Move document 1 to other collection at position 1 via API @@ -2050,7 +1867,6 @@ ;; Moved document should be in new collection at position 1 (is (= other-collection-id (:collection_id moved-doc))) (is (= 1 (:collection_position moved-doc))) - ;; Document 2 should be shifted down in original collection (let [doc2 (t2/select-one :model/Document :id doc2-id)] (is (= 2 (:collection_position doc2))))))))))) @@ -2066,7 +1882,6 @@ :collection_id collection-id :collection_position 5})] (is (= 5 (:collection_position document-result))))) - (testing "collection_position can be updated" (let [document-result (mt/user-http-request :crowberto :post 200 "document/" @@ -2079,7 +1894,6 @@ :put 200 (format "document/%s" document-id) {:collection_position 10})] (is (= 10 (:collection_position updated-result))))) - (testing "collection_position can be set to nil" (let [document-result (mt/user-http-request :crowberto :post 200 "document/" @@ -2126,18 +1940,15 @@ (is (= uuid (:uuid (mt/user-http-request :crowberto :post 200 (format "document/%d/public-link" (:id document)))))))))) - (mt/with-temp [:model/Document document {:name "Test Document" :document (documents.test-util/text->prose-mirror-ast "Test content")}] (testing "Test that we *cannot* share a Document if we aren't admins" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 (format "document/%d/public-link" (:id document)))))) - (testing "Test that we *cannot* share a Document if the setting is disabled" (mt/with-temporary-setting-values [enable-public-sharing false] (is (= "Public sharing is not enabled." (mt/user-http-request :crowberto :post 400 (format "document/%d/public-link" (:id document)))))))) - (testing "Test that we get a 404 if the Document doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :post 404 (format "document/%d/public-link" Integer/MAX_VALUE))))))) @@ -2149,18 +1960,15 @@ (mt/with-temp [:model/Document document (document-with-public-link {})] (mt/user-http-request :crowberto :delete 204 (format "document/%d/public-link" (:id document))) (is (not (t2/exists? :model/Document :id (:id document), :public_uuid (:public_uuid document)))))) - (testing "Test that we *cannot* unshare a Document if we are not admins" (mt/with-temp [:model/Document document (document-with-public-link {})] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "document/%d/public-link" (:id document))))))) - (testing "Test that we get a 404 if Document isn't shared" (mt/with-temp [:model/Document document {:name "Test Document" :document (documents.test-util/text->prose-mirror-ast "Test content")}] (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "document/%d/public-link" (:id document))))))) - (testing "Test that we get a 404 if Document doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "document/%d/public-link" Integer/MAX_VALUE)))))))) @@ -2216,7 +2024,6 @@ (mt/with-temp [:model/Document document (document-with-public-link {})] (is (= "An error occurred." (mt/client :get 400 (str "public/document/" (:public_uuid document)))))))) - (testing "Should get a 404 if the Document doesn't exist" (mt/with-temporary-setting-values [enable-public-sharing true] (is (= "Not found." @@ -2282,7 +2089,6 @@ :content [{:type "cardEmbed" :attrs {:id card-id}}]}}] (t2/update! :model/Card card-id {:document_id doc-id}) - (mt/with-group-for-user [group :rasta {:name "Rasta Group"}] (testing "Read-only users can download" (perms/grant-collection-read-permissions! group coll-id) @@ -2294,7 +2100,6 @@ :pivot_results false})] (is (some? response)) (is (string? response)))) - (testing "No access returns 403" (perms/revoke-collection-permissions! group coll-id) (mt/user-http-request :rasta @@ -2303,7 +2108,6 @@ {:parameters [] :format_rows false :pivot_results false})) - (testing "Card not in document returns 404" (perms/grant-collection-read-permissions! group coll-id) (mt/with-temp [:model/Card {other-card-id :id} {:name "Other Card" @@ -2316,7 +2120,6 @@ {:parameters [] :format_rows false :pivot_results false}))) - (testing "Archived document returns 404" (t2/update! :model/Document doc-id {:archived true}) (mt/user-http-request :rasta @@ -2342,15 +2145,12 @@ :content [{:type "cardEmbed" :attrs {:id card-id}}]}}] (t2/update! :model/Card card-id {:document_id doc-id}) - (let [all-users-group (perms/all-users-group)] - ;; Grant collection read permissions so user can access the document + ;; Grant collection read permissions so user can access the document (perms/grant-collection-read-permissions! all-users-group coll-id) - (testing "User without download permissions yields permissions error" - ;; Set download permissions to :no (no downloads allowed) for All Users group + ;; Set download permissions to :no (no downloads allowed) for All Users group (data-perms/set-database-permission! all-users-group (mt/id) :perms/download-results :no) - (is (malli= [:map [:status [:= "failed"]] [:error_type [:= "missing-required-permissions"]] @@ -2362,10 +2162,8 @@ {:parameters [] :format_rows false :pivot_results false})))) - (testing "User with limited download permissions (10k rows) can download" (data-perms/set-database-permission! all-users-group (mt/id) :perms/download-results :ten-thousand-rows) - (let [response (mt/user-http-request :rasta :post 200 (format "document/%s/card/%s/query/csv" doc-id card-id) @@ -2374,10 +2172,8 @@ :pivot_results false})] (is (some? response)) (is (string? response)))) - (testing "User with full download permissions can download" (data-perms/set-database-permission! all-users-group (mt/id) :perms/download-results :one-million-rows) - (let [response (mt/user-http-request :rasta :post 200 (format "document/%s/card/%s/query/csv" doc-id card-id) @@ -2412,7 +2208,6 @@ :model/Card {card-id :id} {:name "Regular Card" :collection_id regular-id :dataset_query (mt/mbql-query venues)}] - (testing "Throws exception when document references non-remote-synced item" (let [response (mt/user-http-request :crowberto :post 400 "document/" @@ -2433,7 +2228,6 @@ :model/Card {card-id :id} {:name "Remote-Synced Card" :collection_id remote-synced-id :dataset_query (mt/mbql-query venues)}] - (testing "Successfully creates document when referencing remote-synced item" (let [response (mt/user-http-request :crowberto :post 200 "document/" @@ -2460,7 +2254,6 @@ :model/Card {regular-card-id :id} {:name "Regular Card" :collection_id regular-id :dataset_query (mt/mbql-query venues)}] - (testing "Can reference remote-synced item" (let [response (mt/user-http-request :crowberto :post 200 "document/" @@ -2468,7 +2261,6 @@ :collection_id regular-id :document (prose-mirror-with-smartlink "Link" remote-card-id)})] (is (= "Doc with remote ref" (:name response))))) - (testing "Can reference regular item" (let [response (mt/user-http-request :crowberto :post 200 "document/" @@ -2493,7 +2285,6 @@ :model/Card {card-id :id} {:name "Regular Card" :collection_id regular-id :dataset_query (mt/mbql-query venues)}] - (testing "Throws exception when updating to reference non-remote-synced item" (let [response (mt/user-http-request :crowberto :put 400 (format "document/%s" doc-id) @@ -2513,7 +2304,6 @@ :model/Card {card-id :id} {:name "Remote-Synced Card" :collection_id remote-synced-id :dataset_query (mt/mbql-query venues)}] - (testing "Successfully updates when referencing remote-synced item" (let [response (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) @@ -2537,12 +2327,10 @@ :model/Document {doc-id :id} {:name "Doc with remote ref" :collection_id remote-synced-id :document (prose-mirror-with-smartlink "Link" card-id)}] - (testing "Does not throw an exception" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:collection_id regular-id}) - (is (= regular-id (:collection_id (t2/select-one :model/Document :id doc-id)))))))))) (deftest move-document-with-remote-synced-refs-to-root-collection-test @@ -2558,12 +2346,10 @@ :model/Document {doc-id :id} {:name "Doc with remote ref" :collection_id remote-synced-id :document (prose-mirror-with-smartlink "Link" card-id)}] - (testing "Does not throw an exception" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) {:collection_id nil}) - (is (nil? (:collection_id (t2/select-one :model/Document :id doc-id)))))))))) (deftest move-document-without-remote-synced-refs-out-of-remote-synced-collection-test @@ -2579,7 +2365,6 @@ :model/Document {doc-id :id} {:name "Doc without refs" :collection_id remote-synced-id :document (documents.test-util/text->prose-mirror-ast "No references")}] - (testing "Successfully moves when document has no remote-synced references" (let [response (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) @@ -2603,14 +2388,12 @@ :model/Document {doc-id :id} {:name "Doc with regular ref" :collection_id regular-id :document (prose-mirror-with-smartlink "Link" card-id)}] - (testing "Throws exception when moving into remote-synced collection" (let [response (mt/user-http-request :crowberto :put 400 (format "document/%s" doc-id) {:collection_id remote-synced-id})] (is (= "Uses content that is not remote synced." (:message response))) - - ;; Verify document was NOT moved + ;; Verify document was NOT moved (is (= regular-id (:collection_id (t2/select-one :model/Document :id doc-id))) "Document should remain in regular collection")))))))) @@ -2633,14 +2416,12 @@ :model/Document {doc-id :id} {:name "Test Doc" :collection_id remote-synced-id :document (documents.test-util/text->prose-mirror-ast "Initial")}] - (testing "Throws exception when adding non-remote-synced ref and staying in remote-synced collection" (let [response (mt/user-http-request :crowberto :put 400 (format "document/%s" doc-id) {:document (prose-mirror-with-smartlink "Bad" regular-card-id) :collection_id remote-synced-id})] (is (= "Uses content that is not remote synced." (:message response))))) - (testing "Does not throw when moving to a regular collection" (mt/user-http-request :crowberto :put 200 (format "document/%s" doc-id) diff --git a/test/metabase/documents/models/dashboard_card_test.clj b/test/metabase/documents/models/dashboard_card_test.clj index d7a631b7649b..373dfce87447 100644 --- a/test/metabase/documents/models/dashboard_card_test.clj +++ b/test/metabase/documents/models/dashboard_card_test.clj @@ -21,7 +21,6 @@ :col 0}])] (is (= 1 (count result))) (is (= (:id normal-card) (:card_id (first result)))))) - (testing "Adding an in_document card throws an exception" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -33,7 +32,6 @@ :size_y 3 :row 1 :col 0}])))))) - (testing "Cannot add multiple cards if any of them has a document_id" (mt/with-temp [:model/Dashboard dashboard {} :model/Document document {} @@ -63,6 +61,5 @@ :size_y 3 :row 0 :col 8}]))) - ;; Verify that no cards were added due to transaction rollback (is (= 0 (t2/count :model/DashboardCard :dashboard_id (:id dashboard)))))))) diff --git a/test/metabase/documents/models/document_test.clj b/test/metabase/documents/models/document_test.clj index e46b836a0c89..6658d56a92a2 100644 --- a/test/metabase/documents/models/document_test.clj +++ b/test/metabase/documents/models/document_test.clj @@ -47,7 +47,6 @@ (let [updated-count (document/sync-document-cards-collection! document-id collection-id)] ;; Should update exactly 2 cards (is (= 2 updated-count)) - ;; Verify the document cards were updated (let [updated-card1 (t2/select-one :model/Card :id card1-id) updated-card2 (t2/select-one :model/Card :id card2-id) @@ -68,7 +67,6 @@ (let [updated-count (document/sync-document-cards-collection! document-id collection-id)] ;; Should update 0 cards since no cards have matching document_id (is (= 0 updated-count)) - ;; Verify the card with nil document_id was not affected (let [unchanged-card (t2/select-one :model/Card :id nil-document-card-id)] (is (nil? (:collection_id unchanged-card)))))))) @@ -95,10 +93,8 @@ :model/Card {card2-id :id} {:name "Card 2" :document_id document-id :collection_id old-collection-id}] - ;; Update the document's collection_id (t2/update! :model/Document document-id {:collection_id new-collection-id}) - ;; Verify that associated cards were updated to match the new collection (is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id)))) (is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id))))))) @@ -112,10 +108,8 @@ :model/Card {card-id :id} {:name "Card" :document_id document-id :collection_id collection-id}] - ;; Move document to no collection (nil) (t2/update! :model/Document document-id {:collection_id nil}) - ;; Verify that the card's collection_id was updated to nil (is (nil? (:collection_id (t2/select-one :model/Card :id card-id))))))) @@ -138,13 +132,10 @@ ;; Card that should NOT be updated (no document_id) :model/Card {regular-card-id :id} {:name "Regular Card" :collection_id old-collection-id}] - ;; Update the document's collection_id (t2/update! :model/Document document-id {:collection_id new-collection-id}) - ;; Verify only the correct card was updated (is (= new-collection-id (:collection_id (t2/select-one :model/Card :id in-document-card-id)))) - ;; Verify other cards were NOT updated (is (= old-collection-id (:collection_id (t2/select-one :model/Card :id question-card-id)))) (is (= old-collection-id (:collection_id (t2/select-one :model/Card :id regular-card-id))))))) @@ -162,21 +153,17 @@ :document_id document-id :collection_id regular-collection-id :dataset_query (mt/mbql-query venues)}] - (testing "moving document to personal collection moves associated cards" (mt/with-current-user user-id ;; As the personal collection owner, update should succeed (t2/update! :model/Document document-id {:collection_id personal-collection-id}) - ;; Verify both document and card moved to personal collection (is (= personal-collection-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= personal-collection-id (:collection_id (t2/select-one :model/Card :id card-id)))))) - (testing "moving document from personal collection works" (mt/with-current-user user-id ;; Move back to regular collection (t2/update! :model/Document document-id {:collection_id regular-collection-id}) - ;; Verify both document and card moved back (is (= regular-collection-id (:collection_id (t2/select-one :model/Document :id document-id)))) (is (= regular-collection-id (:collection_id (t2/select-one :model/Card :id card-id)))))))))) @@ -191,7 +178,6 @@ ;; Grant write permissions to both collections (perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id) (perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id) - ;; Should not throw any exception (is (some? (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))) @@ -204,7 +190,6 @@ (mt/with-current-user user-id ;; Grant write permission only to new collection (perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id) - ;; Should throw 403 exception (is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that." (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))) @@ -218,7 +203,6 @@ (mt/with-current-user user-id ;; Grant write permission only to old collection (perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id) - ;; Should throw 403 exception (is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that." (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))) @@ -244,7 +228,6 @@ (mt/with-current-user user-id ;; Grant write permission to new collection (perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id) - ;; Should not throw any exception when old collection is nil (is (some? (document/validate-collection-move-permissions nil new-collection-id)))))))) @@ -256,7 +239,6 @@ (mt/with-current-user user-id ;; Grant write permission to old collection (perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id) - ;; Should not throw any exception when new collection is nil (is (nil? (document/validate-collection-move-permissions old-collection-id nil)))))))) @@ -275,7 +257,6 @@ (mt/with-current-user user-id ;; Grant write permission to old collection (perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id) - ;; Use a non-existent collection ID (let [non-existent-id 999999] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid Request." @@ -291,7 +272,6 @@ ;; Grant write permissions to both collections (perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id) (perms/grant-collection-readwrite-permissions! (perms/all-users-group) archived-collection-id) - ;; Should throw 400 exception for archived collection (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid" (document/validate-collection-move-permissions old-collection-id archived-collection-id)))))))) @@ -343,17 +323,14 @@ (is (some? (get-in doc [:creator :first_name]))) (is (some? (get-in doc [:creator :last_name]))) (is (some? (get-in doc [:creator :email]))))) - (testing "creators are correctly matched to documents" (let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs)) doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs)) doc3 (first (filter #(= doc3-id (:id %)) hydrated-docs))] (is (= user1-id (get-in doc1 [:creator :id]))) (is (= "Alice" (get-in doc1 [:creator :first_name]))) - (is (= user2-id (get-in doc2 [:creator :id]))) (is (= "Bob" (get-in doc2 [:creator :first_name]))) - (is (= user1-id (get-in doc3 [:creator :id]))) (is (= "Alice" (get-in doc3 [:creator :first_name]))))))))) @@ -372,7 +349,6 @@ (is (= 2 (count (:cards hydrated-doc)))) (is (contains? (:cards hydrated-doc) card1-id)) (is (contains? (:cards hydrated-doc) card2-id))) - (testing "cards contain correct data" (is (= "Card 1" (get-in hydrated-doc [:cards card1-id :name]))) (is (= "Card 2" (get-in hydrated-doc [:cards card2-id :name]))) @@ -416,7 +392,6 @@ (is (= 3 (count hydrated-docs))) (doseq [doc hydrated-docs] (is (map? (:cards doc))))) - (testing "cards are correctly matched to documents" (let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs)) doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs)) @@ -425,11 +400,9 @@ (is (= 2 (count (:cards doc1)))) (is (contains? (:cards doc1) card1-id)) (is (contains? (:cards doc1) card2-id))) - (testing "document 2 has one card" (is (= 1 (count (:cards doc2)))) (is (contains? (:cards doc2) card3-id))) - (testing "document 3 has no cards" (is (empty? (:cards doc3)))))))))) @@ -442,12 +415,10 @@ (let [document (t2/select-one :model/Document :id document-id)] (testing "collection_position is stored and retrieved correctly" (is (= 5 (:collection_position document))))) - (testing "collection_position can be updated" (t2/update! :model/Document document-id {:collection_position 10}) (let [updated-document (t2/select-one :model/Document :id document-id)] (is (= 10 (:collection_position updated-document))))) - (testing "collection_position can be set to nil" (t2/update! :model/Document document-id {:collection_position nil}) (let [updated-document (t2/select-one :model/Document :id document-id)] diff --git a/test/metabase/documents/recent_views_test.clj b/test/metabase/documents/recent_views_test.clj index b218f86a50e6..023243d664d2 100644 --- a/test/metabase/documents/recent_views_test.clj +++ b/test/metabase/documents/recent_views_test.clj @@ -55,7 +55,6 @@ :archived false}] (let [results (#'recent-views.model/document-recents [doc1-id doc2-id])] (is (= 2 (count results))) - ;; Check first document (let [doc1 (first (filter #(= (:id %) doc1-id) results))] (is (= "Document 1" (:name doc1))) @@ -64,7 +63,6 @@ (is (= coll-id (:collection_id doc1))) (is (= "Test Collection" (:collection_name doc1))) (is (= "official" (:collection_authority_level doc1)))) - ;; Check second document (let [doc2 (first (filter #(= (:id %) doc2-id) results))] (is (= "Document 2" (:name doc2))) @@ -101,7 +99,6 @@ :archived false}] (let [results (#'recent-views.model/document-recents [doc1-id doc2-id])] (is (= 2 (count results))) - ;; Document from archived collection should have nil collection info (let [doc1 (first (filter #(= (:id %) doc1-id) results))] (is (= "Document in Archived Collection" (:name doc1))) @@ -109,7 +106,6 @@ (is (nil? (:collection_id doc1))) (is (nil? (:collection_name doc1))) (is (nil? (:collection_authority_level doc1)))) - ;; Document from active collection should have collection info (let [doc2 (first (filter #(= (:id %) doc2-id) results))] (is (= "Document in Active Collection" (:name doc2))) @@ -152,17 +148,15 @@ ;; Should return unique documents even if IDs are duplicated (is (= 2 (count results))) (is (= #{doc1-id doc2-id} (set (map :id results))))))) - (deftest recents-api-with-documents-premium-feature-test (testing "/recents API returns documents when :document premium feature is enabled" (mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"} :model/Document {doc-id :id} {:name "Test Document" :collection_id coll-id :archived false}] - ;; Add document to recent views + ;; Add document to recent views (activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view) - - ;; Call the API + ;; Call the API (let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])] (is (some #(and (= (:model %) "document") (= (:id %) doc-id) diff --git a/test/metabase/documents/revisions/impl_test.clj b/test/metabase/documents/revisions/impl_test.clj index 1765aee539e6..e87b51cd18cf 100644 --- a/test/metabase/documents/revisions/impl_test.clj +++ b/test/metabase/documents/revisions/impl_test.clj @@ -73,12 +73,10 @@ _ (t2/update! :model/Document doc-id {:name "Updated Document" :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}}) updated-document (t2/select-one :model/Document :id doc-id)] - (testing "document was updated" (is (= "Updated Document" (:name updated-document))) (is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]} (:document updated-document)))) - (testing "reversion restores original state" (revision/revert-to-revision! :model/Document doc-id (mt/user->id :crowberto) original-serialized) (let [reverted-document (t2/select-one :model/Document :id doc-id)] diff --git a/test/metabase/documents/revisions/integration_test.clj b/test/metabase/documents/revisions/integration_test.clj index 1f04ef07983c..5b3c706227fa 100644 --- a/test/metabase/documents/revisions/integration_test.clj +++ b/test/metabase/documents/revisions/integration_test.clj @@ -82,7 +82,6 @@ :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Initial content"}]}]} :creator_id (mt/user->id :rasta)}] (create-document-revision! doc-id true :rasta) - (testing "GET /api/revision/document/:id returns Document revisions" (let [revisions (get-document-revisions doc-id)] (is (= 1 (count revisions))) @@ -92,11 +91,9 @@ (is (= "created this." (:description revision))) (is (=? @rasta-revision-info (:user revision))) (is (= config/mb-version-string (:metabase_version revision)))))) - (testing "Multiple revisions are returned in correct order" (t2/update! :model/Document doc-id {:name "Updated Document"}) (create-document-revision! doc-id false :rasta) - (let [revisions (get-document-revisions doc-id)] (is (= 2 (count revisions))) (let [[latest-revision creation-revision] revisions] @@ -111,15 +108,12 @@ :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]} :creator_id (mt/user->id :rasta)}] (create-document-revision! doc-id true :rasta) - (testing "Document can be updated and reverted" (t2/update! :model/Document doc-id {:name "Updated Document" :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}}) (create-document-revision! doc-id false :rasta) - (let [revisions (revision/revisions :model/Document doc-id) [_ {original-revision-id :id}] revisions] - (testing "Revert via API endpoint" (let [revert-response (mt/user-http-request :rasta :post "revision/revert" {:entity :document @@ -128,13 +122,11 @@ (is (:is_reversion revert-response)) (is (= "reverted to an earlier version." (:description revert-response))) (is (=? @rasta-revision-info (:user revert-response))))) - (testing "Document state is restored" (let [reverted-document (t2/select-one :model/Document :id doc-id)] (is (= "Original Document" (:name reverted-document))) (is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]} (:document reverted-document))))) - (testing "Reversion creates new revision entry" (let [final-revisions (get-document-revisions doc-id)] (is (= 3 (count final-revisions))) @@ -153,14 +145,11 @@ (create-document-revision! (:id document) true :crowberto) (t2/update! :model/Document (:id document) {:name "Updated Private Document"}) (create-document-revision! (:id document) false :crowberto) - (let [document-id (u/the-id document) [_ {prev-rev-id :id}] (revision/revisions :model/Document document-id)] - (testing "User without permissions cannot access revisions" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "revision/document/%d" document-id))))) - (testing "User without permissions cannot revert" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "revision/revert" @@ -173,7 +162,6 @@ (mt/with-temp [:model/Document {doc-id :id} {:name "Cross-boundary Document" :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Enterprise content"}]}]} :creator_id (mt/user->id :rasta)}] - (testing "Document revisions work with OSS revision/revisions function" (create-document-revision! doc-id true :rasta) (let [revisions (revision/revisions :model/Document doc-id)] @@ -182,17 +170,14 @@ (let [revision (first revisions)] (is (= doc-id (:model_id revision))) (is (= "Document" (:model revision)))))) - (testing "Document revisions work with OSS revision API routes" (let [revisions-response (mt/user-http-request :rasta :get (format "revision/document/%d" doc-id))] (is (seq revisions-response)) (is (every? #(contains? % :is_creation) revisions-response)) (is (every? #(contains? % :description) revisions-response)))) - (testing "Document reversion integrates with OSS revert endpoint" (t2/update! :model/Document doc-id {:name "Modified Document"}) (create-document-revision! doc-id false :rasta) - (let [[_ {original-rev-id :id}] (revision/revisions :model/Document doc-id) revert-response (mt/user-http-request :rasta :post "revision/revert" {:entity :document @@ -212,13 +197,11 @@ (mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Event Test Document" :document {:type "doc" :content []} :creator_id (mt/user->id :rasta)}] - (testing "Create event triggers revision creation" (events/publish-event! :event/document-create {:object document :user-id (mt/user->id :rasta)}) (let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)] (is revision) (is (:is_creation revision)))) - (testing "Update event triggers revision creation" (let [updated-document (assoc document :name "Updated Event Document")] (events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)}) @@ -227,7 +210,6 @@ (let [latest-revision (first revisions)] (is (not (:is_creation latest-revision))) (is (= "Updated Event Document" (get-in latest-revision [:object :name]))))))) - (testing "Events were published correctly" (is (>= (count @events-received) 2)) (is (some #(= :event/document-create (first %)) @events-received)) @@ -239,21 +221,17 @@ :document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]} :creator_id (mt/user->id :rasta)}] (create-document-revision! doc-id true :rasta) - (testing "Name change produces correct diff and description" (t2/update! :model/Document doc-id {:name "Updated Title"}) (create-document-revision! doc-id false :rasta) - (let [revisions (get-document-revisions doc-id) update-revision (first revisions)] (is (= "Original Title" (get-in update-revision [:diff :before :name]))) (is (= "Updated Title" (get-in update-revision [:diff :after :name]))) (is (str/includes? (:description update-revision) "renamed")))) - (testing "Content change produces diff" (t2/update! :model/Document doc-id {:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "New content"}]}]}}) (create-document-revision! doc-id false :rasta) - (let [revisions (get-document-revisions doc-id) content-revision (first revisions)] (is (= {:content [{:content [{:text "Original content"}]}]} diff --git a/test/metabase/documents/search_test.clj b/test/metabase/documents/search_test.clj index 3df6cef09f0a..5c5a6fd7c54b 100644 --- a/test/metabase/documents/search_test.clj +++ b/test/metabase/documents/search_test.clj @@ -42,7 +42,6 @@ :model/Card document-card (-> (card-with-name-and-query card-name) (assoc :document_id (u/the-id document))) :model/Card regular-card (card-with-name-and-query regular-card-name)] - (testing "Search API excludes document cards" (let [results (mt/user-http-request :crowberto :get 200 "search" :q card-name :models "card")] (is (not (some #(= (:id %) (u/the-id document-card)) (:data results))) @@ -69,18 +68,15 @@ :document (text->prose-mirror-ast "content")}] ;; Give user read access to first collection only (perms/grant-collection-read-permissions! (perms-group/all-users) coll-id) - (testing "Regular user sees only documents in accessible collections" (let [results (mt/user-http-request :rasta :get 200 "search" :q "Document" :models "document")] (is (= #{doc-in-public-coll} (set (map :id (:data results)))) "Should only see document in accessible collection"))) - (testing "Regular user cannot see archived documents (no write perms)" (let [results (mt/user-http-request :rasta :get 200 "search" :q "Archived" :archived true :models "document")] (is (empty? (:data results)) "Should not see archived document without write permissions"))) - (testing "Admin can see all documents including archived" (let [regular-results (mt/user-http-request :crowberto :get 200 "search" :q "Document" :models "document") archived-results (mt/user-http-request :crowberto :get 200 "search" :q "Archived" :archived true :models "document")] @@ -90,7 +86,6 @@ (is (= #{doc-archived} (set (map :id (:data archived-results)))) "Admin should see archived documents"))) - (testing "User with write permissions can see archived documents" ;; Give user write access to first collection (perms/grant-collection-readwrite-permissions! (perms-group/all-users) coll-id) @@ -104,13 +99,11 @@ (mt/with-temp [:model/Document {doc-id :id} {:name "Viewed Document" :document (text->prose-mirror-ast "content") :view_count 0}] - (testing "Document has initial state" (let [doc (t2/select-one :model/Document :id doc-id)] (is (= 0 (:view_count doc))) ;; last_viewed_at has a default timestamp from migration, not nil (is (some? (:last_viewed_at doc))))) - (testing "Publishing document-read event works without errors" ;; The actual view count increment is batched and asynchronous ;; So we test that the event publishes successfully @@ -118,7 +111,6 @@ {:object-id doc-id :user-id (mt/user->id :crowberto)})) "Document read event should publish successfully")) - (testing "Document with higher view count appears in search" ;; Manually set view count to test search integration (t2/update! :model/Document doc-id {:view_count 5 @@ -131,7 +123,6 @@ (is (= doc-id (:id doc-result))) ;; View count affects scoring but isn't directly in result (is (some #(= "view-count" (:name %)) (:scores doc-result)))))))) - (testing "Document with recent view appears in search results" (let [recent-time (t/minus (t/offset-date-time) (t/minutes 5))] (t2/update! :model/Document doc-id {:last_viewed_at recent-time}) diff --git a/test/metabase/documents/view_log_test.clj b/test/metabase/documents/view_log_test.clj index 501b879f342b..5eea44291593 100644 --- a/test/metabase/documents/view_log_test.clj +++ b/test/metabase/documents/view_log_test.clj @@ -86,7 +86,6 @@ (-> (t2/select-one-fn :last_viewed_at :model/Document (:id document)) t/offset-date-time (.withNano 0)))))) - (testing "if the existing last_viewed_at is greater than the updating values, do not override it" (mt/with-temp [:model/Collection collection {} :model/User user {} @@ -147,10 +146,8 @@ :user_id (:id user) :model "document" :model_id (:id document)))) - ;; Publish document read event (events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)}) - ;; Verify recent view was created (let [recent-view (t2/select-one :model/RecentViews :user_id (:id user) diff --git a/test/metabase/driver/common/parameters/parse_test.clj b/test/metabase/driver/common/parameters/parse_test.clj index 4ca544902899..73826dbefb5b 100644 --- a/test/metabase/driver/common/parameters/parse_test.clj +++ b/test/metabase/driver/common/parameters/parse_test.clj @@ -114,7 +114,6 @@ (is (= expected (normalize-tokens (params.parse/parse s))) (format "%s should get parsed to %s" (pr-str s) (pr-str expected)))))) - (testing "Testing that invalid/unterminated template params/clauses throw an exception" (doseq [invalid ["select * from foo [[where bar = {{baz}} " "select * from foo [[where bar = {{baz]]" @@ -130,7 +129,6 @@ (testing "SQL comments are ignored when handle-sql-comments = false, e.g. in Mongo driver queries" (doseq [[query result] [["{{{foo}}: -- {{bar}}}" ["{" (param "foo") ": -- " (param "bar") "}"]] - ["{{{foo}}: \"/* {{bar}} */\"}" ["{" (param "foo") ": \"/* " (param "bar") " */\"}"]]]] (is (= result (normalize-tokens (params.parse/parse query false))))))) diff --git a/test/metabase/driver/common/parameters/values_test.clj b/test/metabase/driver/common/parameters/values_test.clj index 0a47c13d6c69..476ee492bdb1 100644 --- a/test/metabase/driver/common/parameters/values_test.clj +++ b/test/metabase/driver/common/parameters/values_test.clj @@ -36,13 +36,11 @@ (#'params.values/value-for-tag {:name "id", :display-name "ID", :type :text, :required true, :default "100"} [{:type :category, :target [:variable [:template-tag "id"]], :value "2"}])))) - (testing "Specified value, targeted by ID" (is (= "2" (#'params.values/value-for-tag {:name "id", :id test-uuid, :display-name "ID", :type :text, :required true, :default "100"} [{:type :category, :target [:variable [:template-tag {:id test-uuid}]], :value "2"}])))) - (testing "Multiple values with new operators" (is (= 20 (#'params.values/value-for-tag @@ -52,38 +50,31 @@ (#'params.values/value-for-tag {:name "number_filter", :display-name "ID", :type :number, :required true, :default "100"} [{:type :number/=, :value ["20" "40"], :target [:variable [:template-tag "number_filter"]]}])))) - (testing "Unspecified value" (is (= params/no-value (#'params.values/value-for-tag {:name "id", :display-name "ID", :type :text} nil)))) - (testing "Unspecified value when required" (is (thrown? Exception (#'params.values/value-for-tag {:name "id", :display-name "ID", :required true, :type :text} nil)))) - (testing "Empty value when required" (is (thrown? Exception (#'params.values/value-for-tag {:name "id", :id test-uuid, :display-name "ID", :required true, :type :text} [{:type :category, :target [:variable [:template-tag {:id test-uuid}]], :value nil}])))) - (testing "Default used with unspecified value" (is (= "100" (#'params.values/value-for-tag {:name "id", :display-name "ID", :type :text, :required true, :default "100"} nil)))) - (testing "Default not used with empty value" (is (= params/no-value (#'params.values/value-for-tag {:name "id", :id test-uuid, :display-name "ID", :type :text, :default "100"} [{:type :category, :target [:variable [:template-tag {:id test-uuid}]], :value nil}])))) - (testing "Default used with empty value when required" (is (= "100" (#'params.values/value-for-tag {:name "id", :id test-uuid, :display-name "ID", :type :text, :required true, :default "100"} [{:type :category, :target [:variable [:template-tag {:id test-uuid}]], :value nil}])))) - (testing "BigInteger value" (is (= 9223372036854775808 (#'params.values/value-for-tag @@ -893,7 +884,6 @@ :id "4636d745-1467-4a70-ba20-2a08069d77ff" :display-name "CreatedAt" :widget-type :date/all-options}}] - (testing "with no parameters given, no value" (is (=? {"createdAt" {:field {:lib/type :metadata/column} :value params/no-value}} diff --git a/test/metabase/driver/common_test.clj b/test/metabase/driver/common_test.clj index 451e75a5fb5d..863649500478 100644 --- a/test/metabase/driver/common_test.clj +++ b/test/metabase/driver/common_test.clj @@ -125,7 +125,6 @@ (mt/as-admin (try (driver/create-table! driver db-id qualified-table-name column-definitions {}) - (testing "insert-from-source! should insert rows with all basic types correctly" (let [data-source {:type :rows :data data} _ (driver/insert-from-source! driver db-id @@ -156,7 +155,6 @@ (mt/as-admin (try (driver/create-table! driver db-id qualified-table-name column-definitions {}) - (testing "insert-from-source! should insert rows from JSONL file correctly" (let [temp-file (java.io.File/createTempFile "test-data" ".jsonl") col-names (map :name columns) @@ -165,11 +163,9 @@ (into {} (map vector col-names row))) data)] (try - (with-open [writer (java.io.FileWriter. temp-file)] (doseq [row json-rows] (.write writer (str (json/encode row) "\n")))) - (let [data-source {:type :jsonl-file :file temp-file} _ (driver/insert-from-source! driver db-id {:name qualified-table-name diff --git a/test/metabase/driver/connection/workspaces_test.clj b/test/metabase/driver/connection/workspaces_test.clj index 4a240f2434a2..d14d09f12628 100644 --- a/test/metabase/driver/connection/workspaces_test.clj +++ b/test/metabase/driver/connection/workspaces_test.clj @@ -8,37 +8,30 @@ (driver.w/with-swapped-connection-details 1 {:user "swap-user" :password "swap-pass"} (is (= {:host "localhost" :user "swap-user" :password "swap-pass"} (driver.w/maybe-swap-details 1 {:host "localhost" :user "original-user" :password "original-pass"}))))) - (testing "maybe-swap-details returns details unchanged when no swap exists" (driver.w/with-swapped-connection-details 1 {:user "swap-user"} (is (= {:host "localhost" :user "original-user"} (driver.w/maybe-swap-details 2 {:host "localhost" :user "original-user"}))))) - (testing "maybe-swap-details supports deep merge for nested maps" (driver.w/with-swapped-connection-details 1 {:ssl {:key-store-password "new-pass"}} (is (= {:host "localhost" :ssl {:enabled true :key-store-password "new-pass"}} (driver.w/maybe-swap-details 1 {:host "localhost" :ssl {:enabled true :key-store-password "old-pass"}}))))) - (testing "deep merge adds new keys to nested maps" (driver.w/with-swapped-connection-details 1 {:ssl {:new-key "new-value"}} (is (= {:host "localhost" :ssl {:enabled true :new-key "new-value"}} (driver.w/maybe-swap-details 1 {:host "localhost" :ssl {:enabled true}}))))) - (testing "deep merge replaces nested map with non-map value" (driver.w/with-swapped-connection-details 1 {:ssl "disabled"} (is (= {:host "localhost" :ssl "disabled"} (driver.w/maybe-swap-details 1 {:host "localhost" :ssl {:enabled true :key-store "path"}}))))) - (testing "deep merge adds nested map where none existed" (driver.w/with-swapped-connection-details 1 {:ssl {:enabled true}} (is (= {:host "localhost" :ssl {:enabled true}} (driver.w/maybe-swap-details 1 {:host "localhost"}))))) - (testing "deep merge works with multiple levels of nesting" (driver.w/with-swapped-connection-details 1 {:advanced {:ssl {:cert {:path "/new/path"}}}} (is (= {:host "localhost" :advanced {:timeout 30 :ssl {:enabled true :cert {:path "/new/path"}}}} (driver.w/maybe-swap-details 1 {:host "localhost" :advanced {:timeout 30 :ssl {:enabled true :cert {:path "/old/path"}}}}))))) - (testing "nested swaps for the same database throw an exception" (driver.w/with-swapped-connection-details 1 {:user "outer"} (is (thrown-with-msg? diff --git a/test/metabase/driver/connection_test.clj b/test/metabase/driver/connection_test.clj index bc257e965492..c9966e79aeb7 100644 --- a/test/metabase/driver/connection_test.clj +++ b/test/metabase/driver/connection_test.clj @@ -94,7 +94,6 @@ (driver.w/with-swapped-connection-details 1 {:user "ws-user" :password "ws-pass"} (is (=? {:host "read-host" :user "ws-user" :password "ws-pass" :port 5432} (driver.conn/effective-details database)))))) - (mt/when-ee-evailable (mt/with-premium-features #{:writable-connection} (testing "effective-details applies workspace swap AFTER write-data merge" @@ -106,7 +105,6 @@ (driver.conn/with-write-connection (is (=? {:host "host" :user "ws-user" :password "ws-pass" :port 5432 :write true} (driver.conn/effective-details database))))))) - (testing "without workspace swap, effective-details is unchanged (regression)" (let [database {:lib/type :metadata/database :id 1 diff --git a/test/metabase/driver/h2_test.clj b/test/metabase/driver/h2_test.clj index 0bb540db63f1..f3e7222abefc 100644 --- a/test/metabase/driver/h2_test.clj +++ b/test/metabase/driver/h2_test.clj @@ -249,7 +249,6 @@ "insert into venues (name) values ('bill')" "create table venues" "alter table venues add column address varchar(255)") - (are [query] (= false (#'h2/every-command-allowed-for-actions? (#'h2/classify-query (mt/id) query))) "select * from venues; update venues set name = 'stomp'; CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return \"y4tacker\";}'; @@ -257,9 +256,7 @@ "select * from venues; update venues set name = 'stomp'; CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return \"y4tacker\";}';" "CREATE ALIAS EXEC AS 'String shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(cmd);return \"y4tacker\";}';") - (is (= nil (#'h2/check-action-commands-allowed {:database (mt/id) :native {:query nil}}))) - (is (= nil (#'h2/check-action-commands-allowed {:database (mt/id) :engine :h2 diff --git a/test/metabase/driver/mysql_test.clj b/test/metabase/driver/mysql_test.clj index 788bd2ae2a4b..f1eb4d35aba3 100644 --- a/test/metabase/driver/mysql_test.clj +++ b/test/metabase/driver/mysql_test.clj @@ -190,7 +190,6 @@ {:name "id", :base_type :type/Integer, :semantic_type :type/PK} {:name "thing", :base_type :type/Text, :semantic_type :type/Category}} (db->fields (mt/db))))) - (testing "if someone says specifies `tinyInt1isBit=false`, it should come back as a number instead" (mt/with-temp [:model/Database db {:engine "mysql" :details (assoc (:details (mt/db)) @@ -219,7 +218,6 @@ field-metadata)] (testing "Model has boolean metadata" (is (= :type/Boolean (:base-type boolean-col)))) - (testing "Can query model with boolean filter" (let [query (as-> (lib/query mp (lib.metadata/card mp 1)) $q (lib/filter $q (lib/= (m/find-first #(= (:name %) "number-of-cans") (lib/fieldable-columns $q)) @@ -273,7 +271,6 @@ (testing "Should add a `+` if needed to offset" (is (= "+00:00" (timezone {:global_tz "PDT", :system_tz "UTC", :offset "00:00"}))))) - (testing "real timezone query doesn't fail" (is (nil? (try (driver/db-default-timezone driver/*driver* (mt/db)) @@ -304,7 +301,6 @@ (testing "date formatting when system-timezone == report-timezone" (is (= ["2018-04-18T00:00:00+08:00"] (run-query-with-report-timezone "Asia/Hong_Kong")))) - ;; [August, 2018] ;; This tests a similar scenario, but one in which the JVM timezone is in Hong Kong, but the report timezone ;; is in Los Angeles. The Joda Time date parsing functions for the most part default to UTC. Our tests all run @@ -401,9 +397,9 @@ (jdbc/execute! spec [sql])) true (catch java.sql.SQLSyntaxErrorException se - ;; if an error is received with SYSTEM VERSIONING mentioned, the version - ;; of mysql or mariadb being tested against does not support system versioning, - ;; so do not continue + ;; if an error is received with SYSTEM VERSIONING mentioned, the version + ;; of mysql or mariadb being tested against does not support system versioning, + ;; so do not continue (if (re-matches #".*VERSIONING'.*" (.getMessage se)) false (throw se))))] @@ -511,7 +507,6 @@ "GROUP BY attempts.date " "ORDER BY attempts.date ASC") (some-> (qp.compile/compile query) :query pretty-sql)))))) - (testing "trunc-with-format should not cast a field if it is already a DATETIME" (is (= ["SELECT STR_TO_DATE(DATE_FORMAT(CAST(`field` AS datetime), '%Y'), '%Y')"] (sql.qp/format-honeysql :mysql {:select [[(#'mysql/trunc-with-format "%Y" :field)]]}))) @@ -959,12 +954,10 @@ "CREATE TABLE `fullaccess_table` (id INTEGER);" "CREATE USER 'sync_writable_test_user' IDENTIFIED BY 'password';"]] (jdbc/execute! spec stmt)) - (doseq [stmt ["GRANT SELECT ON sync_writable_test.`readonly_table` TO 'sync_writable_test_user'" "GRANT SELECT, INSERT ON sync_writable_test.`readwrite_table` TO 'sync_writable_test_user'" "GRANT SELECT, INSERT, UPDATE, DELETE ON sync_writable_test.`fullaccess_table` TO 'sync_writable_test_user'"]] (jdbc/execute! spec stmt)) - (let [user-connection-details (assoc details :user "sync_writable_test_user" :password "password" @@ -980,7 +973,6 @@ (testing "After granting full access to all tables and re-syncing" (doseq [table-name ["readonly_table" "readwrite_table"]] (jdbc/execute! spec (format "GRANT INSERT, UPDATE, DELETE ON sync_writable_test.`%s` TO 'sync_writable_test_user'" table-name))) - (sync/sync-database! database) (is (= {"readonly_table" true "readwrite_table" true @@ -1002,7 +994,6 @@ "CREATE USER 'partial_revokes_test_user' IDENTIFIED BY 'password';" "GRANT SELECT, INSERT, UPDATE, DELETE ON partial_revokes_test.test_table TO 'partial_revokes_test_user'"]] (jdbc/execute! spec stmt)) - (let [user-connection-details (assoc details :user "partial_revokes_test_user" :password "password" @@ -1014,28 +1005,23 @@ (jdbc/execute! spec "SET GLOBAL partial_revokes = OFF;") (is (true? (driver/database-supports? driver/*driver* :metadata/table-writable-check database)) "Should support metadata/table-writable-check when partial_revokes is OFF")) - (testing "With partial_revokes ON, metadata/table-writable-check is not supported" (jdbc/execute! spec "SET GLOBAL partial_revokes = ON;") (is (false? (driver/database-supports? driver/*driver* :metadata/table-writable-check database)) "Should not support metadata/table-writable-check when partial_revokes is ON") - (sync/sync-database! database) (is (= {"test_table" nil} (t2/select-fn->fn :name :is_writable :model/Table :db_id (:id database))) "is_writable should sync to nil when partial_revokes is ON")) - (testing "Revoke some permissions with partial_revokes ON" (jdbc/execute! spec "REVOKE INSERT ON partial_revokes_test.test_table FROM 'partial_revokes_test_user';") (is (false? (driver/database-supports? driver/*driver* :metadata/table-writable-check database)) "Should still not support metadata/table-writable-check after partial revoke") - ;; Sync database again and verify is_writable is still nil (sync/sync-database! database) (is (= {"test_table" nil} (t2/select-fn->fn :name :is_writable :model/Table :db_id (:id database))) "is_writable should still be nil after partial revoke")))) - (finally ;; Clean up: Reset partial_revokes to OFF before exiting (jdbc/execute! spec "SET GLOBAL partial_revokes = OFF;") diff --git a/test/metabase/driver/postgres_test.clj b/test/metabase/driver/postgres_test.clj index ff4cc91a22b7..4382a08e29cd 100644 --- a/test/metabase/driver/postgres_test.clj +++ b/test/metabase/driver/postgres_test.clj @@ -1815,7 +1815,6 @@ ["TABLE" "PARTITIONED TABLE" "VIEW" "FOREIGN TABLE" "MATERIALIZED VIEW"])) (into #{} (map #(dissoc % :estimated_row_count)) (#'postgres/get-tables (mt/db) schemas tables)))))] - (doseq [stmt ["CREATE TABLE public.table (id INTEGER, type TEXT);" "CREATE UNIQUE INDEX idx_table_type ON public.table(type);" "CREATE TABLE public.partition_table (id INTEGER) PARTITION BY RANGE (id);" @@ -1917,21 +1916,16 @@ (jdbc/with-db-connection [conn (sql-jdbc.conn/connection-details->spec :postgres details)] (try (jdbc/execute! conn "CREATE SCHEMA IF NOT EXISTS sync_test_schema") - (doseq [stmt ["CREATE TABLE sync_test_schema.readonly_table (id INTEGER);" "CREATE TABLE sync_test_schema.readwrite_table (id INTEGER);" "CREATE TABLE sync_test_schema.fullaccess_table (id INTEGER);"]] (jdbc/execute! conn stmt)) - (jdbc/execute! conn "DROP USER IF EXISTS sync_writable_test_user") (jdbc/execute! conn "CREATE USER sync_writable_test_user WITH PASSWORD 'password'") - (jdbc/execute! conn "GRANT USAGE ON SCHEMA sync_test_schema TO sync_writable_test_user") - (jdbc/execute! conn "GRANT SELECT ON sync_test_schema.readonly_table TO sync_writable_test_user") (jdbc/execute! conn "GRANT SELECT, INSERT ON sync_test_schema.readwrite_table TO sync_writable_test_user") (jdbc/execute! conn "GRANT SELECT, INSERT, UPDATE, DELETE ON sync_test_schema.fullaccess_table TO sync_writable_test_user") - (let [user-connection-details (assoc details :user "sync_writable_test_user" :password "password")] @@ -2179,7 +2173,6 @@ pg-cancel-ex))))) (is (= 0 (count (into [] (cancel-messages) (log-messages)))) "Query cancellation exceptions should not be logged"))) - (binding [qp.pipeline/*canceled-chan* (a/promise-chan)] (future (Thread/sleep 400) diff --git a/test/metabase/driver/sql/parameters/substitute_test.clj b/test/metabase/driver/sql/parameters/substitute_test.clj index 0da105ff509b..88fdda39f0e4 100644 --- a/test/metabase/driver/sql/parameters/substitute_test.clj +++ b/test/metabase/driver/sql/parameters/substitute_test.clj @@ -131,7 +131,7 @@ (let [;; Use high IDs that won't collide with existing test metadata parent-field-id 999901 nested-field-id 999902 - ;; Create a metadata provider that has a parent struct field and a nested child field + ;; Create a metadata provider that has a parent struct field and a nested child field metadata-provider (lib.tu/merged-mock-metadata-provider meta/metadata-provider @@ -411,7 +411,6 @@ :params ["toucan"]} (substitute-e2e "SELECT * FROM bird_facts WHERE toucans_are_cool = {{toucans_are_cool}} AND bird_type = {{bird_type}}" {"toucans_are_cool" true, "bird_type" "toucan"})))) - (testing "Should throw an Exception if a required param is missing" (is (thrown? Exception @@ -425,115 +424,93 @@ :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool}}]]" {"toucans_are_cool" true}))) - (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{ toucans_are_cool }}]]" {"toucans_are_cool" true}))) - (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool }}]]" {"toucans_are_cool" true}))) - (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{ toucans_are_cool}}]]" {"toucans_are_cool" true}))) - (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool_2}}]]" {"toucans_are_cool_2" true}))) - (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE AND bird_type = 'toucan'" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool}} AND bird_type = 'toucan']]" {"toucans_are_cool" true}))) - (testing "Two parameters in an optional" (is (= {:query "SELECT * FROM bird_facts WHERE toucans_are_cool = TRUE AND bird_type = ?" :params ["toucan"]} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool}} AND bird_type = {{bird_type}}]]" {"toucans_are_cool" true, "bird_type" "toucan"})))) - (is (= {:query "SELECT * FROM bird_facts" :params []} (substitute-e2e "SELECT * FROM bird_facts [[WHERE toucans_are_cool = {{toucans_are_cool}}]]" nil))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > 5" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" 5}))) - (testing "make sure nil gets substitute-e2ed in as `NULL`" (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > NULL" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" nil})))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > TRUE" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" true}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > FALSE" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" false}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > ?" :params ["abc"]} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" "abc"}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > ?" :params ["yo' mama"]} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" {"num_toucans" "yo' mama"}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]]" nil))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > 2 AND total_birds > 5" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" {"num_toucans" 2, "total_birds" 5}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND total_birds > 5" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" {"total_birds" 5}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > 3" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" {"num_toucans" 3}))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" nil))) - (is (= {:query "SELECT * FROM toucanneries WHERE bird_type = ? AND num_toucans > 2 AND total_birds > 5" :params ["toucan"]} (substitute-e2e "SELECT * FROM toucanneries WHERE bird_type = {{bird_type}} [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" {"bird_type" "toucan", "num_toucans" 2, "total_birds" 5}))) - (testing "should throw an Exception if a required param is missing" (is (thrown? Exception (substitute-e2e "SELECT * FROM toucanneries WHERE bird_type = {{bird_type}} [[AND num_toucans > {{num_toucans}}]] [[AND total_birds > {{total_birds}}]]" {"num_toucans" 2, "total_birds" 5})))) - (is (= {:query "SELECT * FROM toucanneries WHERE TRUE AND num_toucans > 5 AND num_toucans < 5" :params []} (substitute-e2e "SELECT * FROM toucanneries WHERE TRUE [[AND num_toucans > {{num_toucans}}]] [[AND num_toucans < {{num_toucans}}]]" {"num_toucans" 5}))) - (testing "Make sure that substitutions still work if the substitution contains brackets inside it (#3657)" (is (= {:query "select * from foobars where foobars.id in (string_to_array(100, ',')::integer[])" :params []} diff --git a/test/metabase/driver/sql/transforms_test.clj b/test/metabase/driver/sql/transforms_test.clj index bd215cbb072b..5e236a0c45fc 100644 --- a/test/metabase/driver/sql/transforms_test.clj +++ b/test/metabase/driver/sql/transforms_test.clj @@ -25,7 +25,6 @@ (is (or (re-find #"(?i)INTO\s+.*my_table.*FROM" (first result)) (re-find #"(?i)CREATE\s+TABLE.*AS" (first result)) (re-find #"(?i)CREATE\s+.*TABLE.*my_table" (first result))))))) - (testing "schema-qualified table name" (let [result (driver/compile-transform driver/*driver* {:query {:query "SELECT * FROM products"} @@ -62,7 +61,6 @@ (is (re-find #"(?i)DROP\s+TABLE\s+IF\s+EXISTS" (first result)))) (testing "includes table name" (is (re-find #"my_table" (first result)))))) - (testing "schema-qualified table name" (let [result (driver/compile-drop-table driver/*driver* :my_schema/my_table)] (testing "returns a vector" @@ -91,7 +89,6 @@ (is (vector? drop-result)) (is (>= (count compile-result) 1)) (is (>= (count drop-result) 1))) - (testing "results can be assembled into a queries list" (let [queries [drop-result compile-result]] (is (every? vector? queries)) @@ -118,7 +115,6 @@ (is (vector? result)) (is (string? (first result))) (is (re-find #"my_table" (first result))))) - (testing "schema-qualified table name" (let [result (sql.qp/format-honeysql driver/*driver* (keyword "schema/my_table"))] (is (vector? result)) @@ -142,7 +138,6 @@ (is (re-find #"(?i)INSERT\s+INTO.*my_table" (first result)))) (testing "includes SELECT statement" (is (re-find #"(?i)SELECT" (first result)))))) - (testing "schema-qualified table" (let [result (driver/compile-insert driver/*driver* {:query {:query "SELECT * FROM products"} diff --git a/test/metabase/driver/sql_jdbc/actions_test.clj b/test/metabase/driver/sql_jdbc/actions_test.clj index 2811fee173b8..a7c66c234e51 100644 --- a/test/metabase/driver/sql_jdbc/actions_test.clj +++ b/test/metabase/driver/sql_jdbc/actions_test.clj @@ -293,7 +293,6 @@ (field-id->name (mt/id :user :name)) "New User"} :table-id (mt/id :user)} created-user))) - (testing ":table.row/update" (is (=? {:op :updated :row {(field-id->name (mt/id :user :group-id)) 1 @@ -357,16 +356,13 @@ {:database (mt/id) :table-id (mt/id :group) :row {group-id-col created-group-id}}))))) - (testing "group with user fails due to FK constraint" (let [created-group-id (new-group)] ;; create 2 users (new-user created-group-id) (new-user created-group-id) - (testing "sanity check that we have the users" (is (= 2 (users-of-group created-group-id)))) - (testing "we fail if there are children without cascade-on-delete behavior" (is (thrown-with-msg? ExceptionInfo diff --git a/test/metabase/driver/sql_jdbc/connection_test.clj b/test/metabase/driver/sql_jdbc/connection_test.clj index 3a05e1debdd6..bdc16fe05d11 100644 --- a/test/metabase/driver/sql_jdbc/connection_test.clj +++ b/test/metabase/driver/sql_jdbc/connection_test.clj @@ -83,7 +83,7 @@ (mt/with-temp [:model/Database database {:engine :h2, :details connection-details}] (let [cache-key (pool-cache-key database)] (testing "database id is not in our connection map initially" - ;; deref'ing a var to get the atom. looks weird + ;; deref'ing a var to get the atom. looks weird (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool cache-key)))) (testing "when getting a pooled connection it is now in our connection map" (let [stored-spec (sql-jdbc.conn/db->pooled-connection-spec database) @@ -104,46 +104,41 @@ (let [read-details {:db "mem:read_pool_test"} write-details {:db "mem:write_pool_test"} spec (mdb/spec :h2 read-details)] - ;; Create an in-memory H2 db we can use for the test + ;; Create an in-memory H2 db we can use for the test (sql-jdbc.execute/do-with-connection-with-options :h2 spec {:write? true} (fn [conn] (next.jdbc/execute! conn ["CREATE TABLE IF NOT EXISTS test_tbl (id int)"]) - ;; Use snake_case for column name since deftransforms uses snake_case keys + ;; Use snake_case for column name since deftransforms uses snake_case keys (mt/with-temp [:model/Database database {:engine :h2 :details read-details :write_data_details write-details}] (let [db-id (u/the-id database) default-cache-key [db-id :default] write-cache-key [db-id :write-data]] - ;; Ensure pools are cleared + ;; Ensure pools are cleared (sql-jdbc.conn/invalidate-pool-for-db! database) - (testing "initially no pools exist" (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key))) (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key)))) - (testing "getting a default connection creates only the default pool" (sql-jdbc.conn/db->pooled-connection-spec database) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key)) (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key)))) - (testing "getting a write connection creates a separate write pool" (driver.conn/with-write-connection (sql-jdbc.conn/db->pooled-connection-spec database)) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key)) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key))) - (testing "the two pools are different objects" (let [default-pool (get @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key) write-pool (get @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key)] (is (some? default-pool)) (is (some? write-pool)) (is (not (identical? default-pool write-pool))))) - - ;; Cleanup + ;; Cleanup (sql-jdbc.conn/invalidate-pool-for-db! database))))))))))) (deftest write-connection-reuses-default-pool-when-unconfigured-test @@ -175,14 +170,13 @@ (testing "Write connection pool uses :write-data-details when available" (let [read-details {:db "mem:read_details_db"} write-details {:db "mem:write_details_db"}] - ;; Use snake_case for column name since deftransforms uses snake_case keys + ;; Use snake_case for column name since deftransforms uses snake_case keys (mt/with-temp [:model/Database database {:engine :h2 :details read-details :write_data_details write-details}] (let [db-id (u/the-id database)] - ;; Ensure pools are cleared + ;; Ensure pools are cleared (sql-jdbc.conn/invalidate-pool-for-db! database) - (testing "jdbc-spec-hash differs between default and write connection types" (let [default-hash (#'sql-jdbc.conn/jdbc-spec-hash database) write-hash (driver.conn/with-write-connection @@ -191,20 +185,17 @@ (is (integer? write-hash)) (is (not= default-hash write-hash) "Hash should differ because effective-details returns different details"))) - (testing "hash cache uses composite keys" - ;; Get both pools + ;; Get both pools (sql-jdbc.conn/db->pooled-connection-spec database) (driver.conn/with-write-connection (sql-jdbc.conn/db->pooled-connection-spec database)) - (let [default-cached-hash (get @@#'sql-jdbc.conn/pool-cache-key->jdbc-spec-hash [db-id :default]) write-cached-hash (get @@#'sql-jdbc.conn/pool-cache-key->jdbc-spec-hash [db-id :write-data])] (is (some? default-cached-hash)) (is (some? write-cached-hash)) (is (not= default-cached-hash write-cached-hash)))) - - ;; Cleanup + ;; Cleanup (sql-jdbc.conn/invalidate-pool-for-db! database))))))))) (deftest invalidate-pool-clears-both-connection-types-test @@ -214,22 +205,20 @@ (testing "invalidate-pool-for-db! clears both default and write pools" (let [read-details {:db "mem:invalidate_test"} write-details {:db "mem:invalidate_write_test"}] - ;; Use snake_case for column name since deftransforms uses snake_case keys + ;; Use snake_case for column name since deftransforms uses snake_case keys (mt/with-temp [:model/Database database {:engine :h2 :details read-details :write_data_details write-details}] (let [db-id (u/the-id database) default-cache-key [db-id :default] write-cache-key [db-id :write-data]] - ;; Create both pools + ;; Create both pools (sql-jdbc.conn/db->pooled-connection-spec database) (driver.conn/with-write-connection (sql-jdbc.conn/db->pooled-connection-spec database)) - (testing "both pools exist before invalidation" (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key)) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key))) - (testing "invalidate-pool-for-db! removes both pools" (sql-jdbc.conn/invalidate-pool-for-db! database) (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key))) @@ -479,8 +468,8 @@ (assoc :use-auth-provider true :auth-provider :azure-managed-identity :azure-managed-identity-client-id "client ID")) - ;; we return an expired token which forces a renewal when a second connection is requested - ;; (the first time it is used without checking for expiry) + ;; we return an expired token which forces a renewal when a second connection is requested + ;; (the first time it is used without checking for expiry) expires-in (atom "0") connection-creations (atom 0)] (binding [u.http/*fetch-as-json* (fn [url _headers] @@ -491,16 +480,16 @@ (mt/with-temp [:model/Database oauth-db {:engine (tx/driver), :details oauth-db-details}] (mt/with-db oauth-db (try - ;; since Metabase is running and using the pool of this DB, the sync might fail - ;; if the connection pool is shut down during the sync + ;; since Metabase is running and using the pool of this DB, the sync might fail + ;; if the connection pool is shut down during the sync (sync/sync-database! (mt/db)) (catch Exception _)) - ;; after "fixing" the expiry, we should get a connection from a pool that doesn't get shut down + ;; after "fixing" the expiry, we should get a connection from a pool that doesn't get shut down (reset! expires-in "10000") (sync/sync-database! (mt/db)) (is (= [["Polo Lounge"]] (mt/rows (mt/run-mbql-query venues {:filter [:= $id 60] :fields [$name]})))) - ;; we must have created more than one connection + ;; we must have created more than one connection (is (> @connection-creations 1)))))))))) #_{:clj-kondo/ignore [:metabase/disallow-hardcoded-driver-names-in-tests]} @@ -735,7 +724,6 @@ (is (string? user)) (is (int? port)) (is (string? dbname))) - (mt/with-temporary-setting-values [db-connection-timeout-ms 10000] (is (driver.u/can-connect-with-details? :postgres {:host host @@ -762,7 +750,6 @@ (is (int? port)) (is (string? dbname)) (is (string? ssl-cert))) - (mt/with-temporary-setting-values [db-connection-timeout-ms 10000] (is (driver.u/can-connect-with-details? :mysql {:host host @@ -806,7 +793,6 @@ (is (not= original-spec (sql-jdbc.conn/db->pooled-connection-spec db)))) (testing "Pool was created with swap in swapped pools cache" (is (= 1 (count-swapped-pools-for-db db-id))))))) - (testing "Connection works normally outside swap scope" (sql-jdbc.conn/invalidate-pool-for-db! db) (let [spec (sql-jdbc.conn/db->pooled-connection-spec db)] @@ -850,7 +836,6 @@ #"Nested connection detail swaps are not supported" (driver.w/with-swapped-connection-details db-id {:inner-swap true} (sql-jdbc.conn/db->pooled-connection-spec db))))))))) - (testing "Different databases can have concurrent swaps" (mt/test-drivers (mt/normal-driver-select {:+parent :sql-jdbc}) (let [db-1 (mt/db) @@ -868,12 +853,10 @@ default-cache-key [db-id :default] write-cache-key [db-id :write-data]] (sql-jdbc.conn/invalidate-pool-for-db! db) - ;; Create default canonical pool (sql-jdbc.conn/db->pooled-connection-spec db) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key) "default canonical pool exists") - ;; Insert a fake write pool entry to verify it gets cleared. ;; Needs :datasource so destroy-pool! can handle it. (let [fake-ds (DataSources/pooledDataSource @@ -882,16 +865,13 @@ assoc write-cache-key {:datasource fake-ds})) (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key) "write canonical pool exists") - ;; Create swapped pool (for default connection type) (driver.w/with-swapped-connection-details db-id {:test-swap true} (sql-jdbc.conn/db->pooled-connection-spec db)) (is (= 1 (count-swapped-pools-for-db db-id)) "swapped pool exists") - ;; Now invalidate - should clear all pools (sql-jdbc.conn/invalidate-pool-for-db! db) - (testing "Default canonical pool is cleared" (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool default-cache-key)))) (testing "Write canonical pool is cleared" @@ -916,7 +896,6 @@ (let [pool-1 (sql-jdbc.conn/db->pooled-connection-spec db)] (is (= 1 @create-count)) (is (some? pool-1)) - ;; Simulate password expiration by modifying the cached pool ;; Cache key is [db-id, jdbc-spec-hash-of-swapped-db] (let [cache ^Cache @#'sql-jdbc.conn/swapped-connection-pools @@ -925,7 +904,6 @@ ;; Use a fixed past timestamp (year 2020) to simulate expired password expired-timestamp 1577836800000] (.put cache cache-key (assoc pool-1 :password-expiry-timestamp expired-timestamp))) - ;; Next call should detect invalid pool and recreate (let [pool-2 (sql-jdbc.conn/db->pooled-connection-spec db)] (is (= 2 @create-count) "Pool should have been recreated due to expired password") @@ -949,7 +927,6 @@ (let [pool-1 (sql-jdbc.conn/db->pooled-connection-spec db)] (is (= 1 @create-count)) (is (some? pool-1)) - ;; Simulate closed tunnel by modifying the cached pool ;; We add a tunnel-session that reports as closed ;; Cache key is [db-id, jdbc-spec-hash-of-swapped-db] @@ -957,7 +934,6 @@ swapped-db (update db :details merge swap-details) cache-key (swap-cache-key swapped-db)] (.put cache cache-key (assoc pool-1 :tunnel-session :mock-closed-session))) - ;; Mock ssh-tunnel-open? to return false for our mock session (with-redefs [ssh/ssh-tunnel-open? (fn [pool-spec] (not= :mock-closed-session (:tunnel-session pool-spec)))] @@ -983,7 +959,6 @@ (let [pool-1 (sql-jdbc.conn/db->pooled-connection-spec db)] (is (= 1 @create-count)) (is (some? pool-1)) - ;; Second call should reuse the same pool (let [pool-2 (sql-jdbc.conn/db->pooled-connection-spec db)] (is (= 1 @create-count) "Pool should be reused, not recreated") diff --git a/test/metabase/driver/sql_jdbc/execute_test.clj b/test/metabase/driver/sql_jdbc/execute_test.clj index e8315d1d594b..7dfa0622ac7c 100644 --- a/test/metabase/driver/sql_jdbc/execute_test.clj +++ b/test/metabase/driver/sql_jdbc/execute_test.clj @@ -5,8 +5,10 @@ [metabase.config.core :as config] [metabase.driver :as driver] [metabase.driver.connection :as driver.conn] + [metabase.driver.h2 :as h2] [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] [metabase.test :as mt] + [metabase.test.data.interface :as tx] [metabase.util.malli.registry :as mr]) (:import (java.sql Connection DatabaseMetaData) @@ -130,16 +132,13 @@ (let [ret (original-recursive-fn)] (vswap! connection-option-calls conj [:recursive-connection-check ret]) ret)))] - (driver/do-with-resilient-connection driver/*driver* (mt/id) (fn [driver _db] (let [result (sql-jdbc.execute/try-ensure-open-conn! driver closed-conn)] ;; Should return the new connection (is (identical? new-conn result)) - (is (some #(= % [:recursive-connection-check false]) @connection-option-calls)) - ;; Should have set connection options (since it's non-recursive) (when is-default-options (let [calls @connection-option-calls] @@ -155,13 +154,11 @@ (isClosed [_] false) (isValid [_ _] true))] (is (true? (sql-jdbc.execute/is-conn-open? conn :check-valid? true))))) - (testing "returns false when connection is closed" (let [conn (reify Connection (isClosed [_] true) (isValid [_ _] true))] (is (false? (sql-jdbc.execute/is-conn-open? conn :check-valid? true))))) - (testing "closes connection and returns false when connection is open but not valid" (let [close-called? (atom false) conn (reify Connection @@ -214,3 +211,23 @@ (fn [_conn] nil))) (is (pos? (mt/metric-value system :metabase-db-connection/write-op {:connection-type "write-data"}))))))))))) + +(deftest bad-connection-details-throw-client-error-test + (mt/test-drivers (mt/normal-driver-select {:+parent :sql-jdbc}) + #_{:clj-kondo/ignore [:discouraged-var]} + (mt/with-temp [:model/Database tmp-db {:details (tx/bad-connection-details driver/*driver*) + :engine driver/*driver*}] + ;; It's not straightforward to trigger a `.getConnection` error for some drivers (e.g. sqlite) + ;; so just mock the exception. Also need to mock this h2 method so that the query doesn't fail + ;; before it gets to `do-with-resolved-connection-data-source`. + (with-redefs [h2/check-read-only-statements (fn [_query] nil) + sql-jdbc.execute/do-with-resolved-connection-data-source + (fn [_driver _db-or-id-or-spec _options] + (reify javax.sql.DataSource + (getConnection [_] + (throw (java.sql.SQLException. "connection error")))))] + (let [query {:database (:id tmp-db) + :type :native + :native {:query "SELECT 1"}} + response (mt/user-http-request :crowberto :post 400 "dataset" query)] + (is (= "unable-to-acquire-connection" (:error_type response)))))))) diff --git a/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj b/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj index c5841d7ed3b0..2b1e8953ebd4 100644 --- a/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj +++ b/test/metabase/driver/sql_jdbc/sync/describe_table_test.clj @@ -417,13 +417,11 @@ [{:field-name "array_col" :base-type :type/JSON} {:field-name "string_col" :base-type :type/JSON}] [["[1, 2, 3]" "\"just-a-string-in-a-json-column\""]]]]) - (testing "there should be no nested fields" (is (= #{} (sql-jdbc.sync/describe-nested-field-columns driver/*driver* (mt/db) {:name "json_table" :id (mt/id "json_table")})))) - (sync/sync-database! (mt/db)) (is (=? (if (mysql/mariadb? (mt/db)) #{{:name "id" @@ -479,9 +477,9 @@ (mt/defdataset long-json [["long_json_table" - ;; `short_json` and `long_json` have the same schema, - ;; in the first row, both have an "a" key. - ;; in the second row, both have a "b" key, except `long_json` has a longer value. + ;; `short_json` and `long_json` have the same schema, + ;; in the first row, both have an "a" key. + ;; in the second row, both have a "b" key, except `long_json` has a longer value. [{:field-name "short_json", :base-type :type/JSON} {:field-name "long_json", :base-type :type/JSON}] [[(json/encode {:a "x"}) (json/encode {:a "x"})] @@ -666,7 +664,6 @@ (is (= #{{:type :normal-column-index :value "id"} {:type :normal-column-index :value "indexed"}} (describe-table-indexes (t2/select-one :model/Table (mt/id :single_index)))))) - (testing "for composite indexes, we only care about the 1st column" (jdbc/execute! (sql-jdbc.conn/db->pooled-connection-spec (mt/db)) (sql.tx/create-index-sql driver/*driver* "composite_index" ["first" "second"])) diff --git a/test/metabase/driver/sql_jdbc_test.clj b/test/metabase/driver/sql_jdbc_test.clj index 93852bdc3579..41c67c8394cb 100644 --- a/test/metabase/driver/sql_jdbc_test.clj +++ b/test/metabase/driver/sql_jdbc_test.clj @@ -233,25 +233,25 @@ [[uuid]] (lib/ends-with col (str uuid)) [[uuid]] (lib/contains col (str uuid)) - ;; Test partial uuid values + ;; Test partial uuid values [[uuid]] (lib/contains col (subs (str uuid) 0 1)) [[uuid]] (lib/starts-with col (subs (str uuid) 0 1)) [[uuid]] (lib/ends-with col (subs (str uuid) (dec (count (str uuid))))) - ;; Cannot match a uuid, but should not blow up + ;; Cannot match a uuid, but should not blow up [[uuid]] (lib/!= col "q") [] (lib/= col "q") [] (lib/starts-with col "q") [] (lib/ends-with col "q") [] (lib/contains col "q") - ;; empty/null handling + ;; empty/null handling [] (lib/is-empty col) [[uuid]] (lib/not-empty col) [] (lib/is-null col) [[uuid]] (lib/not-null col) - ;; nil value handling + ;; nil value handling [[uuid]] (lib/!= col nil) [] (lib/= col nil)) (testing ":= uses indexable query" @@ -313,59 +313,46 @@ qualified-temp-2 (qualified-table-name schema temp-table-2) test-data-1 [[1 "Alice"] [2 "Bob"]] test-data-2 [[1 "Product A"] [2 "Product B"]]] - (driver/create-table! driver db-id qualified-table-1 {"id" "INTEGER", "name" "VARCHAR(255)"} {}) (driver/create-table! driver db-id qualified-table-2 {"id" "INTEGER", "name" "VARCHAR(255)"} {}) - (try (driver/insert-into! driver db-id qualified-table-1 ["id" "name"] test-data-1) (driver/insert-into! driver db-id qualified-table-2 ["id" "name"] test-data-2) - (testing "basic rename operations work correctly" (driver/rename-tables! driver db-id {qualified-table-1 qualified-temp-1 qualified-table-2 qualified-temp-2}) - (is (driver/table-exists? driver (mt/db) {:name temp-table-1 :schema schema})) (is (driver/table-exists? driver (mt/db) {:name temp-table-2 :schema schema})) (is (not (driver/table-exists? driver (mt/db) {:name test-table-1 :schema schema}))) (is (not (driver/table-exists? driver (mt/db) {:name test-table-2 :schema schema}))) - (is (= test-data-1 (table-rows qualified-temp-1))) (is (= test-data-2 (table-rows qualified-temp-2))) - (driver/rename-tables! driver db-id {qualified-temp-1 qualified-table-1 qualified-temp-2 qualified-table-2})) - (testing "atomicity: all renames fail if any rename fails" (let [conflict-table (str test-table-2 "_conflict") qualified-conflict (qualified-table-name schema conflict-table)] (driver/create-table! driver db-id qualified-conflict {"id" "INTEGER"} {}) - (try (is (thrown? Exception (driver/rename-tables! driver db-id {qualified-table-1 qualified-temp-1 qualified-table-2 qualified-conflict}))) - (testing "original tables should still exist after failed atomic rename" (is (driver/table-exists? driver (mt/db) {:name test-table-1 :schema schema})) (is (driver/table-exists? driver (mt/db) {:name test-table-2 :schema schema}))) - (testing "temp tables should not exist after failed atomic rename" (is (not (driver/table-exists? driver (mt/db) {:name temp-table-1 :schema schema}))) (is (not (driver/table-exists? driver (mt/db) {:name temp-table-2 :schema schema})))) - (testing "original data should be intact after failed atomic rename" (is (= test-data-1 (table-rows qualified-table-1))) (is (= test-data-2 (table-rows qualified-table-2)))) - (finally (driver/drop-table! driver db-id qualified-conflict))))) - (finally (driver/drop-table! driver db-id qualified-table-1) (driver/drop-table! driver db-id qualified-table-2))))))) @@ -390,7 +377,6 @@ "Renamed table should exist") (is (not (driver/table-exists? driver (mt/db) {:name test-table :schema schema})) "Original table should not exist")) - (finally (when (driver/table-exists? driver (mt/db) {:name renamed-table :schema schema}) (driver/drop-table! driver db-id qualified-renamed)) diff --git a/test/metabase/driver/util_test.clj b/test/metabase/driver/util_test.clj index d23a9a5daac1..689650cdd7db 100644 --- a/test/metabase/driver/util_test.clj +++ b/test/metabase/driver/util_test.clj @@ -50,18 +50,15 @@ (let [cert-string (slurp "./test_resources/ssl/ca.pem") keystore (driver.u/generate-trust-store cert-string)] (is (true? (.containsAlias keystore test-ca-dn))))) - (testing "bad cert provided" (is (thrown? java.security.cert.CertificateException (driver.u/generate-trust-store "fooobar")))) - (testing "multiple certs are read" (let [cert-string (str (slurp "./test_resources/ssl/ca.pem") (slurp "./test_resources/ssl/server.pem")) keystore (driver.u/generate-trust-store cert-string)] (is (.containsAlias keystore test-server-dn)) (is (.containsAlias keystore test-ca-dn)))) - (testing "can create SocketFactory for CA cert" ;; this is a tough method to test - the resulting `SSLSocketFactory` ;; doesn't have any public members to access the underlying `KeyStore` @@ -150,20 +147,17 @@ :secret-test-driver :details-fields)] (is (= expected (mt/select-keys-sequentially expected client-conn-props))))))) - (testing "connection-props-server->client works as expected for info field types" (testing "info fields with placeholder defined are unmodified" (is (= [{:name "test", :type :info, :placeholder "placeholder"}] (driver.u/connection-props-server->client nil [{:name "test", :type :info, :placeholder "placeholder"}])))) - (testing "info fields with getter defined invoke the getter to generate the placeholder" (is (= [{:name "test", :type :info, :placeholder "placeholder"}] (driver.u/connection-props-server->client nil [{:name "test", :type :info, :getter (constantly "placeholder")}])))) - (testing "info fields are omitted if getter returns nil, a non-string value, or throws an exception" (is (= [] (driver.u/connection-props-server->client @@ -224,14 +218,12 @@ a props-by-name :test-driver)))) - (testing "property with single-level visible-if is preserved" (is (= b (#'driver.u/resolve-transitive-visible-if b props-by-name :test-driver)))) - (testing "property with transitive visible-if includes all dependencies" (is (= {:name "prop-c" :visible-if {:prop-b true :prop-a true}} @@ -239,14 +231,12 @@ c props-by-name :test-driver)))))) - (testing "empty visible-if is removed" (is (= {:name "prop-x"} (#'driver.u/resolve-transitive-visible-if {:name "prop-x" :visible-if {}} {} :test-driver)))) - (testing "dependencies on non-existent properties are kept (not filtered)" (let [props-by-name {"prop-a" {:name "prop-a"}}] (is (= {:name "prop-b" :visible-if {:non-existent-prop true}} @@ -254,7 +244,6 @@ {:name "prop-b" :visible-if {:non-existent-prop true}} props-by-name :test-driver))))) - (testing "false dependencies (from removed :checked-section) are filtered out" (let [props-by-name {"prop-a" {:name "prop-a"}}] (is (= {:name "prop-b" :visible-if {:prop-a true}} @@ -263,7 +252,6 @@ :removed-section false}} props-by-name :test-driver))))) - (testing "multi-level transitive dependencies are fully resolved" (let [props-by-name {"prop-a" {:name "prop-a"} "prop-b" {:name "prop-b" :visible-if {:prop-a true}} @@ -276,7 +264,6 @@ {:name "prop-d" :visible-if {:prop-c true}} props-by-name :test-driver))))) - (testing "cycle detection throws exception with appropriate error data" (let [props-by-name {"prop-a" {:name "prop-a" :visible-if {:prop-c true}} "prop-b" {:name "prop-b" :visible-if {:prop-a true}} @@ -300,7 +287,6 @@ (is (= {"prop-a" {:name "prop-a"} "prop-b" {:name "prop-b"}} (#'driver.u/collect-all-props-by-name props))))) - (testing "single group with nested fields" (let [props [{:name "top-level"} {:type :group @@ -311,7 +297,6 @@ "nested-1" {:name "nested-1"} "nested-2" {:name "nested-2"}} (#'driver.u/collect-all-props-by-name props))))) - (testing "deeply nested groups" (let [props [{:name "top"} {:type :group @@ -324,7 +309,6 @@ "level-2" {:name "level-2"} "level-2-b" {:name "level-2-b"}} (#'driver.u/collect-all-props-by-name props))))) - (testing "properties without names are skipped" (let [props [{:name "has-name"} {:type :info :placeholder "no name"}]] @@ -345,7 +329,6 @@ group props-by-name :test-driver))))) - (testing "nested field with transitive dependency" (let [props-by-name {"field-a" {:name "field-a"} "field-b" {:name "field-b" :visible-if {:field-a true}} @@ -359,7 +342,6 @@ group props-by-name :test-driver))))) - (testing "top-level field depending on nested field" (let [props-by-name {"nested-a" {:name "nested-a"} "nested-b" {:name "nested-b" :visible-if {:nested-a true}}} @@ -382,7 +364,6 @@ (is (= :group (:type (first result)))) (is (= {:name "top-field" :visible-if {:nested-field true}} (second result))))) - (testing "nested field depends on top-level field with transitive chain" (let [props [{:name "field-a"} {:name "field-b" :visible-if {:field-a true}} @@ -396,7 +377,6 @@ nested-field (first (:fields group))] (is (= {:field-b true :field-a true} (:visible-if nested-field)))))) - (testing "deeply nested groups with cross-boundary dependencies" (let [props [{:name "root-field"} {:type :group @@ -508,21 +488,18 @@ (is (= 1 (count result))) (is (= "Test message" (:placeholder (first result)))) (is (nil? (:getter (first result))) "Getter should be removed after processing"))) - (testing ":info type with nil getter returns empty vector" (let [info-prop {:name "test-info" :type :info :getter (constantly nil)} result (#'driver.u/process-connection-prop info-prop)] (is (= [] result) "Should return empty vector when getter returns nil"))) - (testing "regular property passes through unchanged" (let [regular-prop {:name "host" :type :string :display-name "Host"} result (#'driver.u/process-connection-prop regular-prop)] (is (= [regular-prop] result)))) - (testing ":group type with simple fields" (let [group-prop {:type :group :container-style ["grid"] @@ -532,7 +509,6 @@ (is (= 1 (count result))) (is (= :group (:type (first result)))) (is (= 2 (count (:fields (first result))))))) - (testing ":group type flattens vectors in :fields array" (let [vector-of-props [{:name "tunnel-host" :type :string} {:name "tunnel-port" :type :integer}] @@ -546,7 +522,6 @@ fields (:fields processed-group)] (is (= 3 (count fields)) "Vector should be flattened into 3 separate fields") (is (= ["ssl" "tunnel-host" "tunnel-port"] (map :name fields)))))) - (testing ":group type recursively processes :info fields" (let [group-prop {:type :group :fields [{:name "regular" :type :string} @@ -559,7 +534,6 @@ (is (= 2 (count fields))) (is (= "Info message" (:placeholder (second fields)))) (is (nil? (:getter (second fields))) "Getter should be removed")))) - (testing ":group type handles nested groups" (let [nested-group {:type :group :fields [{:name "inner1" :type :string} @@ -595,18 +569,15 @@ :type :info :getter (constantly "Group info message")}]}] result (driver.u/connection-props-server->client mock-driver props)] - (testing "top-level :info property is processed" (let [top-info (first (filter #(= "top-level-info" (:name %)) result))] (is (some? top-info)) (is (= "Top level message" (:placeholder top-info))))) - (testing "group contains flattened fields" (let [group (first (filter #(= :group (:type %)) result)) fields (:fields group)] (is (= 4 (count fields)) "Should have 4 fields: ssl, tunnel-host, tunnel-port, group-info") (is (= ["ssl" "tunnel-host" "tunnel-port" "group-info"] (map :name fields))))) - (testing ":info property inside group is processed" (let [group (first (filter #(= :group (:type %)) result)) group-info (first (filter #(= "group-info" (:name %)) (:fields group)))] diff --git a/test/metabase/driver_test.clj b/test/metabase/driver_test.clj index 091f3fd0525a..5b10d1f2e7d5 100644 --- a/test/metabase/driver_test.clj +++ b/test/metabase/driver_test.clj @@ -197,7 +197,6 @@ "created_at" "$_id.created_at" "sum" true}}] formatted-query (driver/prettify-native-form :mongo query)] - (testing "Formatting a mongo query returns a JSON-like string" (is (= (str/join "\n" ["[" @@ -244,19 +243,17 @@ " }" "]"]) formatted-query))) - (testing "The formatted JSON-like string is equivalent to the query" (is (= query (json/decode formatted-query)))) - - ;; TODO(qnkhuat): do we really need to handle case where wrong driver is passed? + ;; TODO(qnkhuat): do we really need to handle case where wrong driver is passed? (let [;; This is a mongodb query, but if you pass in the wrong driver it will attempt the format - ;; This is a corner case since the system should always be using the right driver + ;; This is a corner case since the system should always be using the right driver weird-formatted-query (driver/prettify-native-form :postgres (json/encode query))] (testing "The wrong formatter will change the format..." (is (not= query weird-formatted-query))) (testing "...but the resulting data is still the same" - ;; Bottom line - Use the right driver, but if you use the wrong - ;; one it should be harmless but annoying + ;; Bottom line - Use the right driver, but if you use the wrong + ;; one it should be harmless but annoying (is (= query (json/decode weird-formatted-query)))))))) diff --git a/test/metabase/eid_translation/util_test.clj b/test/metabase/eid_translation/util_test.clj index 4edc46a99e76..d81ef2e82490 100644 --- a/test/metabase/eid_translation/util_test.clj +++ b/test/metabase/eid_translation/util_test.clj @@ -36,15 +36,12 @@ (is (partial= {:ok 2 :total 2} (#'stats/get-translation-count)) "Translations are counted when they do occur") (#'stats/clear-translation-count!)) - (let [samples (t2/select-fn->fn :id :entity_id [:model/Card :id :entity_id] {:limit 100})] (when (seq samples) (doseq [[card-id entity-id] samples] (testing (str "card-id: " card-id " entity-id: " entity-id) - (is (= card-id (eid-translation.util/->id :model/Card card-id))) (is (= card-id (eid-translation.util/->id :card card-id))) - (is (= card-id (eid-translation.util/->id :model/Card entity-id))) (is (= card-id (eid-translation.util/->id :card entity-id))))) (is (malli= [:map [:ok pos-int?] [:total pos-int?]] @@ -148,11 +145,9 @@ (mt/with-temp [:model/Card {card-id :id card-eid :entity_id} {}] (is (= card-id (eid-translation.util/->id-or-404 :card card-id))) (is (= card-id (eid-translation.util/->id-or-404 :card card-eid))))) - (testing "->id-or-404 should throw 404 error for non-existent entity IDs" (is (thrown-with-msg? Exception #"Not found\." (eid-translation.util/->id-or-404 :card "abcdefghijklmnopqrstu")))) - (testing "->id-or-404 should preserve original error for invalid format" (is (thrown-with-msg? Exception #"problem looking up id from entity_id" (eid-translation.util/->id-or-404 :card "invalid-format"))))) diff --git a/test/metabase/embedding/settings_test.clj b/test/metabase/embedding/settings_test.clj index a24731882cfc..2d98706af983 100644 --- a/test/metabase/embedding/settings_test.clj +++ b/test/metabase/embedding/settings_test.clj @@ -49,7 +49,6 @@ (is (= [{:data (merge expected-payload {"event" "interactive_embedding_enabled"}) :user-id (str (mt/user->id :crowberto))}] (filter embedding-event? (snowplow-test/pop-event-data-and-user-id!)))) - (mt/with-temporary-setting-values [enable-embedding-interactive false] (is (= [{:data (merge expected-payload {"event" "interactive_embedding_disabled"}) @@ -158,9 +157,7 @@ (test-enabled-sync! {:mb-enable-embedding-interactive false} :no-op) (test-enabled-sync! {:mb-enable-embedding-interactive true :mb-enable-embedding-static true} :no-op) (test-enabled-sync! {:mb-enable-embedding-interactive false :mb-enable-embedding-static true} :no-op) - (test-enabled-sync! {:mb-enable-embedding true} :sets-all-true) - (test-enabled-sync! {:mb-enable-embedding false} :sets-all-false))) (defn test-origin-sync! [env expected-behavior] @@ -198,13 +195,10 @@ embedding-app-origins-interactive nil embedding-app-origins-sdk nil] (test-origin-sync! {} :no-op) - (test-origin-sync! {:mb-embedding-app-origins-sdk other-ip} :no-op) (test-origin-sync! {:mb-embedding-app-origins-sdk nil} :no-op) - (test-origin-sync! {:mb-embedding-app-origins-interactive other-ip} :no-op) (test-origin-sync! {:mb-embedding-app-origins-interactive nil} :no-op) - (test-origin-sync! {:mb-embedding-app-origin other-ip} :sets-both)))) (deftest disable-cors-on-localhost-validation-test @@ -228,7 +222,6 @@ clojure.lang.ExceptionInfo #"Localhost is not allowed because DISABLE_CORS_ON_LOCALHOST is set." (embed.settings/embedding-app-origins-sdk! "https://example.com localhost:3000"))))))) - (testing "Should allow localhost origins when disable-cors-on-localhost is disabled" (mt/with-premium-features #{:embedding-sdk} (mt/with-temporary-setting-values [enable-embedding-sdk true diff --git a/test/metabase/embedding_rest/api/embed_test.clj b/test/metabase/embedding_rest/api/embed_test.clj index e8feecef04a6..3feaa1205b0b 100644 --- a/test/metabase/embedding_rest/api/embed_test.clj +++ b/test/metabase/embedding_rest/api/embed_test.clj @@ -232,10 +232,10 @@ (deftest parameters-should-include-legacy-template-tags (testing "parameters should get from both template-tags and card.parameters" - ;; in 44 we added card.parameters but we didn't migrate template-tags to parameters - ;; because doing such migration is costly. - ;; so there are cards where some parameters in template-tags does not exist in card.parameters - ;; that why we need to keep concat both of them then dedupe by id + ;; in 44 we added card.parameters but we didn't migrate template-tags to parameters + ;; because doing such migration is costly. + ;; so there are cards where some parameters in template-tags does not exist in card.parameters + ;; that why we need to keep concat both of them then dedupe by id (with-embedding-enabled-and-new-secret-key! (with-temp-card [card (public-test/card-with-embedded-params)] (is (= [;; the parameter with id = "c" exists in both card.parameters and tempalte-tags should have info @@ -246,13 +246,13 @@ :target ["variable" ["template-tag" "c"]], :name "c", :slug "c", - ;; order importance: the default from template-tag is in the final result + ;; order importance: the default from template-tag is in the final result :default "C TAG" :required false :values_source_type "static-list" :values_source_config {:values ["BBQ" "Bakery" "Bar"]}} - ;; the parameter id = "d" is in template-tags, but not card.parameters, - ;; when fetching card we should get it returned + ;; the parameter id = "d" is in template-tags, but not card.parameters, + ;; when fetching card we should get it returned {:id "d", :type "date/single", :target ["variable" ["template-tag" "d"]], @@ -412,7 +412,6 @@ "missing a `:locked` parameter") (is (= "You must specify a value for :venue_id in the JWT." (client/client :get 400 (card-query-url card response-format))))) - (testing "if `:locked` param is present, request should succeed" #_{:clj-kondo/ignore [:deprecated-var]} (test-query-results @@ -420,7 +419,6 @@ (client/real-client :get (response-format->status-code response-format) (card-query-url card response-format {:params {:venue_id 100}}) {:request-options request-options}))) - (testing "If `:locked` parameter is present in URL params, request should fail" (is (= "You can only specify a value for :venue_id in the JWT." (let [url (card-query-url card response-format {:params {:venue_id 100}})] @@ -436,7 +434,6 @@ "`:disabled` parameter") (is (= "You're not allowed to specify a value for :venue_id." (client/client :get 400 (card-query-url card response-format {:params {:venue_id 100}}))))) - (testing "If a `:disabled` param is passed in the URL the request should fail" (is (= "You're not allowed to specify a value for :venue_id." (let [url (card-query-url card response-format)] @@ -455,7 +452,6 @@ (client/client :get 400 (str url (if (str/includes? url "format_rows") "&venue_id=100" "?venue_id=100"))))))) - (testing "If an `:enabled` param is present in the JWT, that's ok" #_{:clj-kondo/ignore [:deprecated-var]} (test-query-results @@ -463,7 +459,6 @@ (client/real-client :get (response-format->status-code response-format) (card-query-url card response-format {:params {:venue_id "enabled"}}) {:request-options request-options}))) - (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" #_{:clj-kondo/ignore [:deprecated-var]} (test-query-results @@ -892,12 +887,10 @@ "missing a `:locked` parameter") (is (= "You must specify a value for :venue_id in the JWT." (client/client :get 400 (dashcard-url dashcard))))) - (testing "if `:locked` param is supplied, request should succeed" (is (=? {:status "completed" :data {:rows [[1]]}} (client/client :get 202 (dashcard-url dashcard {:params {:venue_id 100}}))))) - (testing "if `:locked` parameter is present in URL params, request should fail" (is (= "You must specify a value for :venue_id in the JWT." (client/client :get 400 (str (dashcard-url dashcard) "?venue_id=100")))))))) @@ -909,7 +902,6 @@ "`:disabled` parameter") (is (= "You're not allowed to specify a value for :venue_id." (client/client :get 400 (dashcard-url dashcard {:params {:venue_id 100}}))))) - (testing "If a `:disabled` param is passed in the URL the request should fail" (is (= "You're not allowed to specify a value for :venue_id." (client/client :get 400 (str (dashcard-url dashcard) "?venue_id=200")))))))) @@ -920,12 +912,10 @@ (testing "If `:enabled` param is present in both JWT and the URL, the request should fail" (is (= "You can't specify a value for :venue_id if it's already set in the JWT." (client/client :get 400 (str (dashcard-url dashcard {:params {:venue_id 100}}) "?venue_id=200"))))) - (testing "If an `:enabled` param is present in the JWT, that's ok" (is (=? {:status "completed" :data {:rows [[1]]}} (client/client :get 202 (dashcard-url dashcard {:params {:venue_id 50}}))))) - (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" (is (=? {:status "completed" :data {:rows [[1]]}} @@ -1027,7 +1017,6 @@ (is (false? (:has_more_values response))) (is (set/subset? #{["20th Century Cafe"] ["33 Taps"]} (-> response :values set)))) - (let [response (search field-filter-card (:field-values param-keys) "bar")] (is (set/subset? #{["Barney's Beanery"] ["bigmista's barbecue"]} (-> response :values set))) @@ -1153,7 +1142,6 @@ (is (= {:values [["Fast Food"] ["Food Truck"] ["Seafood"]] :has_more_values false} (chain-filer-test/take-n-values 3 (client/client :get 200 (search-url))))))) - (testing "If an ENABLED constraint param is present in the JWT, that's ok" (testing "\nGET /api/embed/dashboard/:token/params/:param-key/values" (is (= {:values [[40 "Japanese"] [67 "Steakhouse"]] @@ -1163,7 +1151,6 @@ (is (= {:values [] :has_more_values false} (client/client :get 200 (search-url {"price" 4})))))) - (testing "If an ENABLED param is present in query params but *not* the JWT, that's ok" (testing "\nGET /api/embed/dashboard/:token/params/:param-key/values" (is (= {:values [[40 "Japanese"] [67 "Steakhouse"]] @@ -1173,7 +1160,6 @@ (is (= {:values [] :has_more_values false} (client/client :get 200 (str (search-url) "?_PRICE_=4")))))) - (testing "If ENABLED param is present in both JWT and the URL, the request should fail" (doseq [url-fn [values-url search-url] :let [url (str (url-fn {"price" 4}) "?_PRICE_=4")]] @@ -1207,17 +1193,14 @@ (testing (str "\n" url) (is (re= #"Cannot search for values: \"category_(?:(?:name)|(?:id))\" is not an enabled parameter." (client/client :get 400 url)))))) - (testing "Search param enabled\n" (t2/update! :model/Dashboard (:id dashboard) {:embedding_params {"category_id" "enabled", "category_name" "enabled", "price" "locked"}}) - (testing "Requests should fail if the token is missing a locked parameter" (doseq [url [(values-url) (search-url)]] (testing (str "\n" url) (is (= "You must specify a value for :price in the JWT." (client/client :get 400 url)))))) - (testing "if `:locked` param is supplied, request should succeed" (testing "\nGET /api/embed/dashboard/:token/params/:param-key/values" (is (= {:values [[40 "Japanese"] [67 "Steakhouse"]] @@ -1227,7 +1210,6 @@ (is (= {:values [] :has_more_values false} (client/client :get 200 (search-url {"price" 4})))))) - (testing "if `:locked` parameter is present in URL params, request should fail" (doseq [url-fn [values-url search-url] :let [url (url-fn {"price" 4})]] @@ -1244,18 +1226,15 @@ (testing (str "\n" url) (is (re= #"Cannot search for values: \"category_(?:(?:name)|(?:id))\" is not an enabled parameter\." (client/client :get 400 url)))))) - (testing "Search param enabled\n" (t2/update! :model/Dashboard (:id dashboard) {:embedding_params {"category_id" "enabled", "category_name" "enabled", "price" "disabled"}}) - (testing "Requests should fail if the token has a disabled parameter" (doseq [url-fn [values-url search-url] :let [url (url-fn {"price" 4})]] (testing (str "\n" url) (is (= "You're not allowed to specify a value for :price." (client/client :get 400 url)))))) - (testing "Requests should fail if the URL has a disabled parameter" (doseq [url-fn [values-url search-url] :let [url (str (url-fn) "?_PRICE_=4")]] @@ -1281,7 +1260,6 @@ (with-temp-card [card (api.pivots/pivot-card)] (is (= "Embedding is not enabled." (client/client :get 400 (pivot-card-query-url card "")))))))) - (with-embedding-enabled-and-new-secret-key! (let [expected-status 202] (testing "it should be possible to run a Card successfully if you jump through the right hoops..." @@ -1300,12 +1278,10 @@ (is (= "completed" (:status eid-result))) (is (= 6 (count (get-in eid-result [:data :cols])))) (is (= 1144 (count eid-rows))))))) - (testing "check that if embedding *is* enabled globally but not for the Card the request fails" (with-temp-card [card (api.pivots/pivot-card)] (is (= "Embedding is not enabled for this object." (client/client :get 400 (pivot-card-query-url card "")))))) - (testing (str "check that if embedding is enabled globally and for the object that requests fail if they are " "signed with the wrong key") (with-temp-card [card (merge {:enable_embedding true} (api.pivots/pivot-card))] @@ -1386,7 +1362,6 @@ "missing a `:locked` parameter") (is (= "You must specify a value for :abc in the JWT." (client/client :get 400 (pivot-dashcard-url dashcard))))) - (testing "if `:locked` param is supplied, request should succeed" (let [result (client/client :get 202 (pivot-dashcard-url dashcard (:dashboard_id dashcard) {:params {:abc 100}})) rows (mt/rows result)] @@ -1401,7 +1376,6 @@ (is (= "completed" (:status eid-result))) (is (= 6 (count (get-in eid-result [:data :cols])))) (is (= 1144 (count eid-rows))))) - (testing "if `:locked` parameter is present in URL params, request should fail" (is (= "You must specify a value for :abc in the JWT." (client/client :get 400 (str (pivot-dashcard-url dashcard) "?abc=100")))))))))) @@ -1418,7 +1392,6 @@ "`:disabled` parameter") (is (= "You're not allowed to specify a value for :abc." (client/client :get 400 (pivot-dashcard-url dashcard (:dashboard_id dashcard) {:params {:abc 100}}))))) - (testing "If a `:disabled` param is passed in the URL the request should fail" (is (= "You're not allowed to specify a value for :abc." (client/client :get 400 (str (pivot-dashcard-url dashcard) "?abc=200"))))))))) @@ -1438,7 +1411,6 @@ (testing "If `:enabled` param is present in both JWT and the URL, the request should fail" (is (= "You can't specify a value for :abc if it's already set in the JWT." (client/client :get 400 (str (pivot-dashcard-url dashcard (:dashboard_id dashcard) {:params {:abc 100}}) "?abc=200"))))) - (testing "If an `:enabled` param is present in the JWT, that's ok" (let [result (client/client :get 202 (pivot-dashcard-url dashcard (:dashboard_id dashcard) {:params {:abc 100}})) rows (mt/rows result)] @@ -1446,7 +1418,6 @@ (is (= "completed" (:status result))) (is (= 6 (count (get-in result [:data :cols])))) (is (= 1144 (count rows))))) - (testing "If an `:enabled` param is present in the JWT, that's ok with entity-id" (let [eid-result (client/client :get 202 (pivot-dashcard-url dashcard (dashcard->dash-eid dashcard) {:params {:abc 100}})) eid-rows (mt/rows eid-result)] @@ -1454,7 +1425,6 @@ (is (= "completed" (:status eid-result))) (is (= 6 (count (get-in eid-result [:data :cols])))) (is (= 1144 (count eid-rows))))) - (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" (let [result (client/client :get 202 (str (pivot-dashcard-url dashcard) "?abc=200")) rows (mt/rows result)] @@ -1729,6 +1699,29 @@ url (format "embed/dashboard/%s/params/%s/remapping?value=%s" token "user-id-param" value)] (is (= expected (client/client :get 200 url)))))))))) +(deftest dashboard-param-value-remapping-static-list-test + (testing "remapping endpoint returns [value label] for a numeric param backed by a static list" + (with-embedding-enabled-and-new-secret-key! + (mt/with-temp [:model/Dashboard {dashboard-id :id} + {:enable_embedding true + :parameters [{:name "Seats" + :slug "seats" + :id "seats-param" + :type :number/= + :values_source_type "static-list" + :values_source_config {:values [["10" "Ten"] + ["20" "Twenty"] + "30"]}}] + :embedding_params {:seats "enabled"}}] + (let [token (dash-token dashboard-id) + url #(format "embed/dashboard/%s/params/%s/remapping?value=%s" token "seats-param" %)] + (testing "labeled value is returned" + (is (= ["20" "Twenty"] (client/client :get 200 (url "20"))))) + (testing "unlabeled value is returned as a singleton" + (is (= ["30"] (client/client :get 200 (url "30"))))) + (testing "value not in the list falls back to [value]" + (is (= ["999"] (client/client :get 200 (url "999")))))))))) + (deftest card-param-value-remapping-test (let [param-static-list "_STATIC_CATEGORY_" param-static-list-label "_STATIC_CATEGORY_LABEL_" @@ -1892,13 +1885,11 @@ (t2/update! :model/Dashboard (:id dashboard) {:enable_embedding true :embedding_params {"name_contains" "enabled"}}) - (letfn [(dashcard-query-url [params] (format "embed/dashboard/%s/dashcard/%s/card/%s" (dash-token dashboard (when params {:params params})) (:id dashcard) (:id card)))] - (testing "Field filter should work case-insensitively in embedded dashboards" (testing "Query with lowercase 'red' should find venues with 'Red' in the name" (let [response (client/client :get 202 (dashcard-query-url {"name_contains" "red"})) diff --git a/test/metabase/embedding_rest/api/preview_embed_test.clj b/test/metabase/embedding_rest/api/preview_embed_test.clj index f20332b38b00..3627c7ca101d 100644 --- a/test/metabase/embedding_rest/api/preview_embed_test.clj +++ b/test/metabase/embedding_rest/api/preview_embed_test.clj @@ -74,16 +74,13 @@ #_{:clj-kondo/ignore [:deprecated-var]} (embed-test/test-query-results (mt/user-http-request :crowberto :get 202 (card-query-url card)))) - (testing "if the user is not an admin this endpoint should fail" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (card-query-url card))))) - (testing "check that the endpoint doesn't work if embedding isn't enabled" (mt/with-temporary-setting-values [enable-embedding-static false] (is (= "Embedding is not enabled." (mt/user-http-request :crowberto :get 400 (card-query-url card)))))) - (testing "check that if embedding is enabled globally requests fail if they are signed with the wrong key" (is (= "Message seems corrupt or manipulated" (mt/user-http-request :crowberto :get 400 (embed-test/with-new-secret-key! (card-query-url card)))))))))) @@ -96,13 +93,11 @@ (testing "check that if embedding is enabled globally fail if the token is missing a `:locked` parameter" (is (= "You must specify a value for :venue_id in the JWT." (mt/user-http-request :crowberto :get 400 (card-query-url card {:_embedding_params {:venue_id "locked"}}))))) - (testing "if `:locked` param is supplied, request should succeed" #_{:clj-kondo/ignore [:deprecated-var]} (embed-test/test-query-results (mt/user-http-request :crowberto :get 202 (card-query-url card {:_embedding_params {:venue_id "locked"} :params {:venue_id 100}})))) - (testing "if `:locked` parameter is present in URL params, request should fail" (is (= "You can only specify a value for :venue_id in the JWT." (mt/user-http-request :crowberto :get 400 (str (card-query-url card {:_embedding_params {:venue_id "locked"} @@ -118,7 +113,6 @@ (is (= "You're not allowed to specify a value for :venue_id." (mt/user-http-request :crowberto :get 400 (card-query-url card {:_embedding_params {:venue_id "disabled"} :params {:venue_id 100}}))))) - (testing "If a `:disabled` param is passed in the URL the request should fail" (is (= "You're not allowed to specify a value for :venue_id." (mt/user-http-request :crowberto :get 400 (str (card-query-url card {:_embedding_params {:venue_id "disabled"}}) @@ -134,7 +128,6 @@ (mt/user-http-request :crowberto :get 400 (str (card-query-url card {:_embedding_params {:venue_id "enabled"} :params {:venue_id 100}}) "?venue_id=200"))))) - (testing "If an `:enabled` param is present in the JWT, that's ok" #_{:clj-kondo/ignore [:deprecated-var]} (embed-test/test-query-results @@ -266,16 +259,13 @@ #_{:clj-kondo/ignore [:deprecated-var]} (embed-test/test-query-results (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard)))) - (testing "...but if the user is not an admin this endpoint should fail" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (dashcard-url dashcard))))) - (testing "check that the endpoint doesn't work if embedding isn't enabled" (is (= "Embedding is not enabled." (mt/with-temporary-setting-values [enable-embedding-static false] (mt/user-http-request :crowberto :get 400 (dashcard-url dashcard)))))) - (testing "check that if embedding is enabled globally requests fail if they are signed with the wrong key" (is (= "Message seems corrupt or manipulated" (mt/user-http-request :crowberto :get 400 (embed-test/with-new-secret-key! (dashcard-url dashcard)))))))))) @@ -289,13 +279,11 @@ (is (= "You must specify a value for :venue_id in the JWT." (mt/user-http-request :crowberto :get 400 (dashcard-url dashcard {:_embedding_params {:venue_id "locked"}}))))) - (testing "If `:locked` param is supplied, request should succeed" (is (=? {:status "completed" :data {:rows [[1]]}} (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard {:_embedding_params {:venue_id "locked"}, :params {:venue_id 100}}))))) - (testing "If `:locked` parameter is present in URL params, request should fail" (is (= "You can only specify a value for :venue_id in the JWT." (mt/user-http-request :crowberto :get 400 (str (dashcard-url dashcard @@ -311,7 +299,6 @@ (is (= "You're not allowed to specify a value for :venue_id." (mt/user-http-request :crowberto :get 400 (dashcard-url dashcard {:_embedding_params {:venue_id "disabled"}, :params {:venue_id 100}}))))) - (testing "If a `:disabled` param is passed in the URL the request should fail" (is (= "You're not allowed to specify a value for :venue_id." (mt/user-http-request :crowberto :get 400 (str (dashcard-url dashcard {:_embedding_params {:venue_id "disabled"}}) @@ -327,13 +314,11 @@ (mt/user-http-request :crowberto :get 400 (str (dashcard-url dashcard {:_embedding_params {:venue_id "enabled"} :params {:venue_id 100}}) "?venue_id=200"))))) - (testing "If an `:enabled` param is present in the JWT, that's ok" (is (=? {:status "completed" :data {:rows [[1]]}} (mt/user-http-request :crowberto :get 202 (dashcard-url dashcard {:_embedding_params {:venue_id "enabled"} :params {:venue_id 100}}))))) - (testing "If an `:enabled` param is present in URL params but *not* the JWT, that's ok" (is (=? {:status "completed" :data {:rows [[0]]}} @@ -427,20 +412,17 @@ (is (= "completed" (:status result))) (is (= 6 (count (get-in result [:data :cols])))) (is (= 1144 (count rows))))) - (testing "should fail if user is not an admin" (is (= "You don't have permissions to do that." (embed-test/with-embedding-enabled-and-new-secret-key! (embed-test/with-temp-card [card (api.pivots/pivot-card)] (mt/user-http-request :rasta :get 403 (pivot-card-query-url card))))))) - (testing "should fail if embedding is disabled" (is (= "Embedding is not enabled." (mt/with-temporary-setting-values [enable-embedding false] (embed-test/with-new-secret-key! (embed-test/with-temp-card [card (api.pivots/pivot-card)] (mt/user-http-request :crowberto :get 400 (pivot-card-query-url card)))))))) - (testing "should fail if embedding is enabled and the wrong key is used" (is (= "Message seems corrupt or manipulated" (embed-test/with-embedding-enabled-and-new-secret-key! @@ -469,17 +451,14 @@ (is (= "completed" (:status result))) (is (= 6 (count (get-in result [:data :cols])))) (is (= 1144 (count rows))))) - (testing "should fail if user is not an admin" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (pivot-dashcard-url dashcard))))) - (testing "should fail if embedding is disabled" (is (= "Embedding is not enabled." (mt/with-temporary-setting-values [enable-embedding-static false] (embed-test/with-new-secret-key! (mt/user-http-request :crowberto :get 400 (pivot-dashcard-url dashcard))))))) - (testing "should fail if embedding is enabled and the wrong key is used" (is (= "Message seems corrupt or manipulated" (mt/user-http-request :crowberto :get 400 (embed-test/with-new-secret-key! (pivot-dashcard-url dashcard)))))))))))) diff --git a/test/metabase/events/recent_views_test.clj b/test/metabase/events/recent_views_test.clj index b7b1dc94d86a..e515e14acbe3 100644 --- a/test/metabase/events/recent_views_test.clj +++ b/test/metabase/events/recent_views_test.clj @@ -23,14 +23,12 @@ :model "card" :model_id (:id card)} (most-recent-view (mt/user->id :rasta) (:id card) "card"))) - (testing "pinned cards should not be counted" (mt/with-temp [:model/Card card-2 {:creator_id (mt/user->id :rasta)}] (events/publish-event! :event/card-query {:card-id (:id card-2) :user-id (mt/user->id :rasta) :context :collection}) (is (nil? (most-recent-view (mt/user->id :rasta) (:id card-2) "card"))))) - (testing "dashboard subscriptions should not be counted" (mt/with-temp [:model/Card card-2 {:creator_id (mt/user->id :rasta)}] (events/publish-event! :event/card-query {:card-id (:id card-2) @@ -69,7 +67,6 @@ :model "card" :model_id (:id card)} (most-recent-view (mt/user->id :rasta) (:id card) "card")))))) - (testing "card-read events with other contexts should not be recorded" (mt/with-temp [:model/Card card {:creator_id (mt/user->id :rasta)}] (mt/with-test-user :rasta diff --git a/test/metabase/formatter/datetime_test.clj b/test/metabase/formatter/datetime_test.clj index ffc768e6c303..b256520d1e37 100644 --- a/test/metabase/formatter/datetime_test.clj +++ b/test/metabase/formatter/datetime_test.clj @@ -321,10 +321,8 @@ (let [german-result (format-temporal-str "UTC" test-datetime col)] (is (re-find #"Juli" german-result)) (is (= original-jvm-locale (Locale/getDefault))))) - (mt/with-temporary-setting-values [site-locale "fr"] (let [french-result (format-temporal-str "UTC" test-datetime col)] (is (re-find #"juillet" french-result)) (is (= original-jvm-locale (Locale/getDefault))))) - (is (= original-jvm-locale (Locale/getDefault)))))) diff --git a/test/metabase/frontend_errors/api_test.clj b/test/metabase/frontend_errors/api_test.clj index 4cb634a5290d..fcd2270f4399 100644 --- a/test/metabase/frontend_errors/api_test.clj +++ b/test/metabase/frontend_errors/api_test.clj @@ -34,18 +34,15 @@ (let [initial (mt/metric-value system :metabase-frontend/errors {:type "component-crash"})] (is (nil? (mt/user-http-request :rasta :post 204 "frontend-errors" {:type "component-crash"}))) (is (< initial (mt/metric-value system :metabase-frontend/errors {:type "component-crash"})))))) - (testing "POST /api/frontend-errors with type=chart-render-error tracks separately" (mt/with-prometheus-system! [_ system] (let [initial (mt/metric-value system :metabase-frontend/errors {:type "chart-render-error"})] (mt/user-http-request :rasta :post 204 "frontend-errors" {:type "chart-render-error"}) (is (< initial (mt/metric-value system :metabase-frontend/errors {:type "chart-render-error"})))))) - (testing "POST /api/frontend-errors rejects unknown type values" (is (= {:errors {:type "enum of component-crash, chart-render-error"}} (select-keys (mt/user-http-request :rasta :post 400 "frontend-errors" {:type "bogus"}) [:errors])))) - (testing "POST /api/frontend-errors is throttled for the same IP once the threshold is exceeded" (mt/with-prometheus-system! [_ system] (with-throttled-frontend-errors @@ -61,7 +58,6 @@ (is (< initial-count count-after-first-request)) (is (= count-after-first-request count-after-throttling)) (is (=? throttled-response resp)))))) - (testing "POST /api/frontend-errors throttles requests from the same IP even if the browser ID changes" (mt/with-prometheus-system! [_ system] (with-throttled-frontend-errors @@ -76,7 +72,6 @@ count-after-throttling (mt/metric-value system :metabase-frontend/errors {:type "component-crash"})] (is (= (inc initial-count) count-after-throttling)) (is (=? throttled-response resp))))))) - (testing "POST /api/frontend-errors throttles repeated requests from the same IP even without a browser cookie" (mt/with-prometheus-system! [_ system] (with-throttled-frontend-errors @@ -90,7 +85,6 @@ count-after-throttling (mt/metric-value system :metabase-frontend/errors {:type "component-crash"})] (is (= (inc initial-count) count-after-throttling)) (is (=? throttled-response resp))))))) - (testing "POST /api/frontend-errors throttles repeated invalid payloads before validation" (with-throttled-frontend-errors (let [request-options (request-options "device-a")] diff --git a/test/metabase/glossary/api_test.clj b/test/metabase/glossary/api_test.clj index 8b3e0978c772..66095d4d905a 100644 --- a/test/metabase/glossary/api_test.clj +++ b/test/metabase/glossary/api_test.clj @@ -26,7 +26,6 @@ (testing "Response hydrates creator" (is (map? (:creator response))) (is (= crowberto-id (get-in response [:creator :id])))))) - (testing "cannot create glossary entry with missing fields" (is (=? {:errors {:term "value must be a non-blank string."}} (mt/user-http-request :crowberto :post 400 "glossary" @@ -34,7 +33,6 @@ (is (=? {:errors {:definition "value must be a non-blank string."}} (mt/user-http-request :crowberto :post 400 "glossary" {:term "Missing definition field"})))))) - (testing "GET /api/glossary" (let [crowberto-id (mt/user->id :crowberto)] (mt/with-temp [:model/Glossary _g1 {:term "Database" @@ -50,25 +48,21 @@ (is (set/subset? #{"Database" "Query"} (set (map :term data)))) (testing "Response hydrates creator" (is (every? #(map? (:creator %)) data))))) - (testing "can search glossary entries by term" (let [response (mt/user-http-request :rasta :get 200 "glossary" :search "data") data (:data response)] (is (= 2 (count data))) (is (every? #(or (re-find #"(?i)data" (:term %)) (re-find #"(?i)data" (:definition %))) data)))) - (testing "search is case insensitive" (let [response (mt/user-http-request :rasta :get 200 "glossary" :search "DATABASE") data (:data response)] (is (<= 1 (count data))) (is (set/subset? #{"Database"} (set (map :term data)))))) - (testing "search returns empty when no matches" (let [response (mt/user-http-request :rasta :get 200 "glossary" :search (str "nonexistent-" (random-uuid))) data (:data response)] (is (= 0 (count data)))))))) - (testing "PUT /api/glossary/:id" (let [crowberto-id (mt/user->id :crowberto)] (mt/with-temp [:model/Glossary {gid :id} {:term "Old Term" @@ -84,20 +78,17 @@ (testing "Response hydrates creator" (is (map? (:creator response))) (is (= crowberto-id (get-in response [:creator :id])))))) - (testing "returns 404 when updating non-existent entry" (is (= "Not found." (mt/user-http-request :crowberto :put 404 "glossary/99999" {:term "Does not exist" :definition "Does not exist"}))))))) - (testing "DELETE /api/glossary/:id" (mt/with-temp [:model/Glossary {gid :id} {:term "To Delete" :definition "Will be deleted"}] (testing "can delete glossary entry as superuser" (is (= nil (mt/user-http-request :crowberto :delete 204 (str "glossary/" gid)))) (is (nil? (t2/select-one :model/Glossary :id gid)))) - (testing "returns 404 when deleting non-existent entry" (is (= "Not found." (mt/user-http-request :crowberto :delete 404 "glossary/99999"))))))) diff --git a/test/metabase/glossary/models/glossary_test.clj b/test/metabase/glossary/models/glossary_test.clj index 92ac1eca4231..683df1d4148f 100644 --- a/test/metabase/glossary/models/glossary_test.clj +++ b/test/metabase/glossary/models/glossary_test.clj @@ -28,7 +28,6 @@ (is (= "Test" (get-in hydrated [:creator :first_name]))) (is (= "Creator" (get-in hydrated [:creator :last_name]))) (is (= "test.creator@example.com" (get-in hydrated [:creator :email]))))) - (testing "Batch glossary entry hydration" (let [glossary-entries (t2/select :model/Glossary :id [:in [(:id glossary1) (:id glossary2)]]) hydrated (t2/hydrate glossary-entries :creator)] @@ -39,7 +38,6 @@ (is (= user2-id (get-in creators-by-id [(:id glossary2) :id]))) (is (= "Test" (get-in creators-by-id [(:id glossary1) :first_name]))) (is (= "Second" (get-in creators-by-id [(:id glossary2) :first_name])))))) - (testing "Glossary created without creator_id defaults to internal user" (let [glossary-no-creator (t2/insert-returning-instance! :model/Glossary {:term "SQL" diff --git a/test/metabase/graph/core_test.clj b/test/metabase/graph/core_test.clj index 849e5bdaee47..3d124a366863 100644 --- a/test/metabase/graph/core_test.clj +++ b/test/metabase/graph/core_test.clj @@ -9,29 +9,24 @@ 2 #{3} 3 #{}})] (is (nil? (graph/find-cycle g [1]))))) - (testing "no cycle with multiple paths to same node" (let [g (graph/in-memory {1 #{2 3} 2 #{4} 3 #{4} 4 #{}})] (is (nil? (graph/find-cycle g [1]))))) - (testing "self-loop is a cycle" (let [g (graph/in-memory {1 #{1}})] (is (= [1 1] (graph/find-cycle g [1]))))) - (testing "direct cycle between two nodes" (let [g (graph/in-memory {1 #{2} 2 #{1}})] (is (= [1 2 1] (graph/find-cycle g [1]))))) - (testing "longer cycle" (let [g (graph/in-memory {1 #{2} 2 #{3} 3 #{1}})] (is (= [1 2 3 1] (graph/find-cycle g [1]))))) - (testing "cycle not reachable from start" (let [g (graph/in-memory {1 #{2} 2 #{} @@ -39,13 +34,11 @@ 4 #{3}})] ;; Starting from 1, we can't reach the 3->4->3 cycle (is (nil? (graph/find-cycle g [1]))))) - (testing "multiple start nodes - finds cycle from any" (let [g (graph/in-memory {1 #{} 2 #{3} 3 #{2}})] (is (= [2 3 2] (graph/find-cycle g [1 2]))))) - (testing "cycle path excludes prefix before cycle" ;; Graph: 1 -> 2 -> 3 -> 2 (cycle is 2 -> 3 -> 2, prefix is 1) (let [g (graph/in-memory {1 #{2} diff --git a/test/metabase/indexed_entities/api_test.clj b/test/metabase/indexed_entities/api_test.clj index 2929b0f96c95..8ea8b691bbc3 100644 --- a/test/metabase/indexed_entities/api_test.clj +++ b/test/metabase/indexed_entities/api_test.clj @@ -3,7 +3,6 @@ [clojure.test :refer :all] [metabase.analytics.snowplow-test :as snowplow-test] [metabase.test :as mt] - [toucan2.util :as u])) (deftest full-lifecycle-test @@ -49,7 +48,6 @@ (mt/with-non-admin-groups-no-root-collection-perms (mt/user-http-request :rasta :get 403 (str "/model-index/" (:id model-index)))))) - (testing "DELETE" (testing "Must have write access to the underlying model" (mt/with-non-admin-groups-no-root-collection-perms diff --git a/test/metabase/interestingness/chart/stats_test.clj b/test/metabase/interestingness/chart/stats_test.clj index ff8227975184..25ff5d986ff4 100644 --- a/test/metabase/interestingness/chart/stats_test.clj +++ b/test/metabase/interestingness/chart/stats_test.clj @@ -46,7 +46,6 @@ sampled (get-in stats [:series "series_0" :data-points])] (is (=? {:limits {:downsampled-series {"series_0" {:original-count n}}}} stats)) - (is (approx=max-data-points-per-series? sampled) (str "sampled " sampled " should be roughly <= " @#'stats.core/max-data-points-per-series))))) diff --git a/test/metabase/interestingness/impl_test.clj b/test/metabase/interestingness/impl_test.clj index 7adf54150200..94ecc0286bf7 100644 --- a/test/metabase/interestingness/impl_test.clj +++ b/test/metabase/interestingness/impl_test.clj @@ -18,7 +18,6 @@ :type/Dictionary "structured blob" :type/UpdatedTimestamp "updated timestamp" :type/DeletionTimestamp "deletion timestamp")) - (testing "non-penalized types score 1.0 (including FK — x-ray templates use FK columns)" (are [sem-type] (= 1.0 (:score (impl/type-penalty {:semantic-type sem-type}))) @@ -28,7 +27,6 @@ :type/Number :type/FK nil)) - (testing "nil semantic type scores 1.0" (is (= 1.0 (:score (impl/type-penalty {})))))) @@ -45,28 +43,24 @@ (is (= 0.8 (:score result))) (is (map? (:scores result))) (is (= {:semantic-type :type/Category} (:field result))))) - (testing "weighted average of two scorers" (let [result (impl/score-field {(constant-scorer 1.0 "high") 0.75 (constant-scorer 0.5 "mid") 0.25} {})] (is (= 0.875 (:score result))))) - (testing "weights don't need to sum to 1" (let [result (impl/score-field {(constant-scorer 1.0 "a") 3.0 (constant-scorer 0.5 "b") 1.0} {})] (is (= 0.875 (:score result))))) - (testing "hard-zero from any scorer clamps total to at most 0.1" (let [result (impl/score-field {(constant-scorer 1.0 "high") 0.75 (constant-scorer 0.0 "gate") 0.25} {})] (is (<= (:score result) 0.1)))) - (testing "empty scorer map returns 0.5" (is (= 0.5 (:score (impl/score-field {} {})))))) @@ -85,7 +79,6 @@ {:score 0.5 :field :c}]] (is (= [{:score 0.8 :field :a} {:score 0.5 :field :c}] (impl/apply-cutoff 0.5 scored))))) - (testing "empty input returns empty" (is (empty? (impl/apply-cutoff 0.5 []))))) @@ -106,13 +99,10 @@ (deftest ^:parallel nullness-test (testing "0% null scores 1.0" (is (= 1.0 (:score (impl/nullness {:fingerprint {:global {:nil% 0.0}}}))))) - (testing "100% null scores 0.0" (is (= 0.0 (:score (impl/nullness {:fingerprint {:global {:nil% 1.0}}}))))) - (testing "50% null scores 0.5" (is (= 0.5 (:score (impl/nullness {:fingerprint {:global {:nil% 0.5}}}))))) - (testing "missing fingerprint returns 0.5" (is (= 0.5 (:score (impl/nullness {})))))) @@ -122,17 +112,14 @@ (testing "zero sd scores 0.0" (is (= 0.0 (:score (impl/numeric-variance {:fingerprint {:type {:type/Number {:sd 0.0 :avg 10.0}}}}))))) - (testing "q1 = q3 scores low" (is (<= (:score (impl/numeric-variance {:fingerprint {:type {:type/Number {:q1 5.0 :q3 5.0}}}})) 0.1))) - (testing "healthy spread scores high" (let [score (:score (impl/numeric-variance {:fingerprint {:type {:type/Number {:sd 10.0 :avg 50.0 :min 0 :max 100}}}}))] (is (> score 0.3)))) - (testing "non-numeric field returns 0.5" (is (= 0.5 (:score (impl/numeric-variance {})))) (is (= 0.5 (:score (impl/numeric-variance {:fingerprint {:type {:type/Text {}}}})))))) @@ -144,58 +131,47 @@ (is (= 0.5 (:score (impl/distribution-shape {})))) (is (= 0.5 (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:avg 10 :sd 2}}}}))))) - (testing "symmetric low-dominance distribution scores high" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:skewness 0.1 :mode-fraction 0.1}}}}))] (is (>= score 0.9)))) - (testing "heavy skew penalizes score" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:skewness 3.0 :mode-fraction 0.1}}}}))] (is (<= score 0.5)))) - (testing "high mode-dominance scores very low" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Text {:mode-fraction 0.97}}}}))] (is (<= score 0.1)))) - (testing "80% dominance scores low but not critical" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Text {:mode-fraction 0.85}}}}))] (is (>= score 0.15)) (is (<= score 0.3)))) - (testing "combined signals: worst-of-two wins" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:skewness 0.1 :mode-fraction 0.95}}}}))] (is (<= score 0.1)))) - (testing "works on text fields via type/Text fingerprint" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Text {:mode-fraction 0.3}}}}))] (is (>= score 0.9)))) - (testing "extreme excess kurtosis penalizes score" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:excess-kurtosis 20.0}}}}))] (is (<= score 0.2)))) - (testing "near-normal kurtosis scores high" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:excess-kurtosis 0.2}}}}))] (is (>= score 0.9)))) - (testing "high top-3-fraction penalizes score" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Text {:mode-fraction 0.4 :top-3-fraction 0.995}}}}))] (is (<= score 0.15)))) - (testing "high zero-fraction (numeric) penalizes score" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/Number {:zero-fraction 0.96}}}}))] (is (<= score 0.1)))) - (testing "temporal mode-fraction penalizes dumping-ground timestamps" (let [score (:score (impl/distribution-shape {:fingerprint {:type {:type/DateTime {:mode-fraction 0.95}}}}))] diff --git a/test/metabase/legacy_mbql/normalize_test.cljc b/test/metabase/legacy_mbql/normalize_test.cljc index 42ce2896da59..be8e26e1e21c 100644 --- a/test/metabase/legacy_mbql/normalize_test.cljc +++ b/test/metabase/legacy_mbql/normalize_test.cljc @@ -860,7 +860,6 @@ {:query {:breakout [10 20]}} {:query {:breakout [[:field 10 nil] [:field 20 nil]]}}} - "should handle seqs" {{:query {:breakout '(10 20)}} {:query {:breakout [[:field 10 nil] [:field 20 nil]]}} @@ -1042,22 +1041,18 @@ "ORDER BY: MBQL 2 [field direction] should get translated to MBQL 3+ [direction field]" {{:query {:order-by [[[:field-id 10] :asc]]}} {:query {:order-by [[:asc [:field 10 nil]]]}}} - "MBQL 2 old order-by names should be handled" {{:query {:order-by [[10 :ascending]]}} {:query {:order-by [[:asc [:field 10 nil]]]}}} - "field-id should be added if needed" {{:query {:order-by [[10 :asc]]}} {:query {:order-by [[:asc [:field 10 nil]]]}} {:query {:order-by [[:asc 10]]}} {:query {:order-by [[:asc [:field 10 nil]]]}}} - "we should handle seqs no prob" {{:query {:order-by '((1 :ascending))}} {:query {:order-by [[:asc [:field 1 nil]]]}}} - "duplicate order-by clauses should get removed" {{:query {:order-by [[:asc [:field-id 1]] [:desc [:field-id 2]] @@ -1405,7 +1400,6 @@ (testing (str "\n" message) (is (= query (mbql.normalize/normalize query)))))))] - (testing "Keys in native query maps should not get normalized" (test-normalization {:projections [:count] @@ -1415,7 +1409,6 @@ {"$sort" {"_id" 1}} {"$project" {"_id" false, "count" true}}] :collection "venues"})) - (testing "`nil` values inside native :params shouldn't get removed" (test-normalization {:query "SELECT ?" :params [nil]}))))) diff --git a/test/metabase/legacy_mbql/util_test.cljc b/test/metabase/legacy_mbql/util_test.cljc index ee099c4c28cb..1f7e5226e094 100644 --- a/test/metabase/legacy_mbql/util_test.cljc +++ b/test/metabase/legacy_mbql/util_test.cljc @@ -37,16 +37,12 @@ (t/is (= nil (mbql.u/simplify-compound-filter nil)) "does `simplify-compound-filter` return `nil` for empty filter clauses?") - (t/is (= nil (mbql.u/simplify-compound-filter []))) - (t/is (= nil (mbql.u/simplify-compound-filter [nil nil nil]))) - (t/is (= nil (mbql.u/simplify-compound-filter [:and nil nil]))) - (t/is (= nil (mbql.u/simplify-compound-filter [:and nil [:and nil nil nil] nil]))) (t/is (= [:= [:field 1 nil] 2] @@ -479,7 +475,6 @@ (doseq [[[op mode] unit] @#'mbql.u/temporal-extract-ops->unit] (t/is (= [:temporal-extract [:field 1 nil] unit] (#'mbql.u/desugar-temporal-extract [op [:field 1 nil] mode]))) - (t/is (= [:+ [:temporal-extract [:field 1 nil] unit] 1] (#'mbql.u/desugar-temporal-extract [:+ [op [:field 1 nil] mode] 1])))))) @@ -571,17 +566,14 @@ [:field 1 {:temporal-unit :week}] [:relative-datetime 0 :week]] (mbql.u/negate-filter-clause [:time-interval [:field 1 nil] :current :week])))) - (t/testing :time-interval (t/is (= [:!= [:expression "CC" {:temporal-unit :week}] [:relative-datetime 0 :week]] (mbql.u/negate-filter-clause [:time-interval [:expression "CC"] :current :week])))) - (t/testing :is-null (t/is (= [:!= [:field 1 nil] nil] (mbql.u/negate-filter-clause [:is-null [:field 1 nil]])))) - (t/testing :not-null (t/is (= [:= [:field 1 nil] nil] (mbql.u/negate-filter-clause [:not-null [:field 1 nil]])))) diff --git a/test/metabase/lib/aggregation_test.cljc b/test/metabase/lib/aggregation_test.cljc index 67d9d4d0eeb7..c0f1fad4864b 100644 --- a/test/metabase/lib/aggregation_test.cljc +++ b/test/metabase/lib/aggregation_test.cljc @@ -194,7 +194,6 @@ [:field {:base-type :type/Integer, :lib/uuid string?} (meta/id :venues :category-id)]]]}]}] - (testing "with helper function" (is (=? result-query (-> q diff --git a/test/metabase/lib/card_test.cljc b/test/metabase/lib/card_test.cljc index f556ef8cdc9a..2f05663d2cd0 100644 --- a/test/metabase/lib/card_test.cljc +++ b/test/metabase/lib/card_test.cljc @@ -217,7 +217,6 @@ (map #(dissoc % :id :table-id)) sorted) (->> nested lib.metadata.calculation/returned-columns sorted))) - (is (=? (->> (concat (from :source/card (cols-of :orders)) (from :source/card (cols-of :products))) (map #(dissoc % :id :table-id)) @@ -688,7 +687,7 @@ model-query (lib/query meta/metadata-provider (meta/table-metadata :orders)) mp (lib.tu/mock-metadata-provider meta/metadata-provider - ;; intentionally omitting `:dataset-query` + ;; intentionally omitting `:dataset-query` {:cards [{:id model-id :type :model :database-id (meta/id) diff --git a/test/metabase/lib/convert_test.cljc b/test/metabase/lib/convert_test.cljc index 0983320e94aa..4d65b2068992 100644 --- a/test/metabase/lib/convert_test.cljc +++ b/test/metabase/lib/convert_test.cljc @@ -255,7 +255,6 @@ (is (=? [tag [:field 12 nil] "ABC" {:case-sensitive false}] (lib.convert/->legacy-MBQL (lib.options/ensure-uuid [tag {:case-sensitive false} [:field {} 12] "ABC"])))))) - (testing "with multiple arguments (MBQL 5 style)" (testing "->mbql5" (is (=? [tag {:lib/uuid string?} [:field {} 12] "ABC" "HJK" "XYZ"] @@ -264,7 +263,6 @@ [:field {} 12] "ABC" "HJK" "XYZ"] (lib.convert/->mbql5 [tag {:case-sensitive false} [:field 12 nil] "ABC" "HJK" "XYZ"])))) - (testing "->legacy-MBQL" (is (=? [tag {} [:field 12 nil] "ABC" "HJK" "XYZ"] (lib.convert/->legacy-MBQL [tag {} [:field {} 12] "ABC" "HJK" "XYZ"]))) @@ -574,7 +572,6 @@ :breakout [[:field 1677 nil]] :source-table 517} :type :query})) - (test-round-trip {:database 67 :query {:aggregation [[:aggregation-options @@ -881,7 +878,6 @@ {:type :query :database 1} {:type :query}) - (is (nil? (-> {:database 1 :type :query :query {:source-table 224 diff --git a/test/metabase/lib/display_name_test.cljc b/test/metabase/lib/display_name_test.cljc index 08be2f65acc3..28c7ffa6c2e8 100644 --- a/test/metabase/lib/display_name_test.cljc +++ b/test/metabase/lib/display_name_test.cljc @@ -98,14 +98,12 @@ (is (= [{:type :translatable, :value "Total"} {:type :static, :value " של סכום"}] (lib.display-name/parse-column-display-name-parts "Total של סכום" patterns))))) - (testing "Wrapped pattern: value in middle (e.g., French 'Somme de X totale')" (let [patterns [{:prefix "Somme de ", :suffix " totale"}]] (is (= [{:type :static, :value "Somme de "} {:type :translatable, :value "Total"} {:type :static, :value " totale"}] (lib.display-name/parse-column-display-name-parts "Somme de Total totale" patterns))))) - (testing "Nested RTL patterns" (let [patterns [{:prefix "", :suffix " של סכום"} {:prefix "", :suffix " של מינימום"}]] @@ -113,7 +111,6 @@ {:type :static, :value " של מינימום"} {:type :static, :value " של סכום"}] (lib.display-name/parse-column-display-name-parts "Total של מינימום של סכום" patterns))))) - (testing "RTL pattern with join" (let [patterns [{:prefix "", :suffix " של סכום"}]] (is (= [{:type :translatable, :value "Products"} @@ -130,23 +127,19 @@ {:type :static, :value " is between "} {:type :static, :value "100 and 200"}] (lib.display-name/parse-column-display-name-parts "Total is between 100 and 200" nil filter-patterns conjunctions)))) - (testing "Greater than filter" (is (= [{:type :translatable, :value "Total"} {:type :static, :value " is greater than "} {:type :static, :value "100"}] (lib.display-name/parse-column-display-name-parts "Total is greater than 100" nil filter-patterns conjunctions)))) - (testing "Unary filter (not)" (is (= [{:type :static, :value "not "} {:type :translatable, :value "Total"}] (lib.display-name/parse-column-display-name-parts "not Total" nil filter-patterns conjunctions)))) - (testing "Unary filter (is empty)" (is (= [{:type :translatable, :value "Total"} {:type :static, :value " is empty"}] (lib.display-name/parse-column-display-name-parts "Total is empty" nil filter-patterns conjunctions)))) - (testing "Filter with join" (is (= [{:type :translatable, :value "Products"} {:type :static, :value " → "} @@ -154,7 +147,6 @@ {:type :static, :value " is between "} {:type :static, :value "100 and 200"}] (lib.display-name/parse-column-display-name-parts "Products → Total is between 100 and 200" nil filter-patterns conjunctions)))) - (testing "Filter with temporal bucket" (is (= [{:type :translatable, :value "Created At"} {:type :static, :value ": "} @@ -162,13 +154,11 @@ {:type :static, :value " is before "} {:type :static, :value "2024"}] (lib.display-name/parse-column-display-name-parts "Created At: Month is before 2024" nil filter-patterns conjunctions)))) - (testing "Contains filter" (is (= [{:type :translatable, :value "Category"} {:type :static, :value " contains "} {:type :static, :value "Widget"}] (lib.display-name/parse-column-display-name-parts "Category contains Widget" nil filter-patterns conjunctions)))) - (testing "Does not contain filter" (is (= [{:type :translatable, :value "Category"} {:type :static, :value " does not contain "} @@ -187,7 +177,6 @@ (lib.display-name/parse-column-display-name-parts "Review Requested At is not empty or Reviewed At is not empty" nil filter-patterns conjunctions)))) - (testing "Two-item compound filter with 'and'" (is (= [{:type :translatable, :value "Total"} {:type :static, :value " is greater than "} @@ -199,7 +188,6 @@ (lib.display-name/parse-column-display-name-parts "Total is greater than 100 and Price is less than 50" nil filter-patterns conjunctions)))) - (testing "Three-item compound filter" (is (= [{:type :translatable, :value "Total"} {:type :static, :value " is empty"} @@ -220,7 +208,6 @@ (is (= [{:type :static, :value "Sum of "} {:type :translatable, :value "Total"}] (lib.display-name/parse-column-display-name-parts "Sum of Total" agg-patterns filter-patterns)))) - (testing "Plain column name is unchanged when filter patterns are present" (is (= [{:type :translatable, :value "Total"}] (lib.display-name/parse-column-display-name-parts "Total" agg-patterns filter-patterns)))))) diff --git a/test/metabase/lib/drill_thru/automatic_insights_test.cljc b/test/metabase/lib/drill_thru/automatic_insights_test.cljc index 4245df729869..3dbc8cb61c2a 100644 --- a/test/metabase/lib/drill_thru/automatic_insights_test.cljc +++ b/test/metabase/lib/drill_thru/automatic_insights_test.cljc @@ -57,7 +57,7 @@ (and (not (:native? test-case)) (or ;; Any pivot or legend click is good. (#{:pivot :legend} click) - ;; As are cell clicks with at least 1 breakout. + ;; As are cell clicks with at least 1 breakout. (and (= click :cell) (seq (:dimensions context)))))))) (testing "not available at all with xrays disabled" diff --git a/test/metabase/lib/drill_thru/column_extract_test.cljc b/test/metabase/lib/drill_thru/column_extract_test.cljc index 698a9c3e03ef..56c81db5a02a 100644 --- a/test/metabase/lib/drill_thru/column_extract_test.cljc +++ b/test/metabase/lib/drill_thru/column_extract_test.cljc @@ -59,7 +59,7 @@ :drill-type :drill-thru/column-extract :expected {:type :drill-thru/column-extract :extractions datetime-extraction-units - ;; Query unchanged + ;; Query unchanged :query (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :query]) :stage-number -1} :drill-args ["month-of-year"] @@ -167,11 +167,11 @@ :expected-query {:stages [{:expressions [;; The original [:get-day {:lib/expression-name "Day of month"} exp-created-at] - ;; The newly added one + ;; The newly added one [:get-day {:lib/expression-name "Day of month_2"} exp-created-at]]}]} - ;; With a native base, the name gets disambiguated, but there's only one expression rather than 2, - ;; because the original is part of the native query. + ;; With a native base, the name gets disambiguated, but there's only one expression rather than 2, + ;; because the original is part of the native query. :expected-native {:stages [{:expressions [;; The newly added one [:get-day {:lib/expression-name "Day of month_2"} exp-created-at]]}]}})))) @@ -196,10 +196,10 @@ :stage-number -1} :drill-query-native native :drill-args ["day-of-month"] - ;; Aggregated MBQL queries need a new stage appended. + ;; Aggregated MBQL queries need a new stage appended. :expected-query {:stages [(get-in query [:stages 0]) exprs]} - ;; Wrapped native queries have the expression on their only stage. + ;; Wrapped native queries have the expression on their only stage. :expected-native {:stages [(merge (get-in native [:stages 0]) exprs)]}})))) diff --git a/test/metabase/lib/drill_thru/fk_details_test.cljc b/test/metabase/lib/drill_thru/fk_details_test.cljc index 533992f67791..017212d4bfd4 100644 --- a/test/metabase/lib/drill_thru/fk_details_test.cljc +++ b/test/metabase/lib/drill_thru/fk_details_test.cljc @@ -152,7 +152,7 @@ (let [provider (merged-mock/merged-mock-metadata-provider meta/metadata-provider {:fields [;; Make Checkins.VENUE_ID + Checkins.USER_ID into a two-part primary key. - ;; Turn Checkins.ID into a basic numeric field. + ;; Turn Checkins.ID into a basic numeric field. {:id (meta/id :checkins :id) :semantic-type :type/Quantity} {:id (meta/id :checkins :venue-id) @@ -161,8 +161,7 @@ {:id (meta/id :checkins :user-id) :semantic-type :type/PK :fk-target-field-id nil} - - ;; Then turn Orders.USER_ID and Orders.PRODUCT_ID into FKs pointing at Checkins. + ;; Then turn Orders.USER_ID and Orders.PRODUCT_ID into FKs pointing at Checkins. {:id (meta/id :orders :user-id) :semantic-type :type/FK :fk-target-field-id (meta/id :checkins :user-id)} @@ -187,11 +186,10 @@ :expected {:type :drill-thru/fk-details :column {:name "PRODUCT_ID"} :object-id venue-id - ;; TODO: This field actually refers to the source table, not the target one. Is that right? - ;; Tech Debt Issue: #39409 + ;; TODO: This field actually refers to the source table, not the target one. Is that right? + ;; Tech Debt Issue: #39409 :many-pks? false} :expected-query {:stages [{:filters [[:= {} exp-venue-id venue-id]]}]}})) - (testing "preserve any existing filter for another PK on the same table" (testing "existing USER_ID, new \"VENUE_ID\" (really PRODUCT_ID)" (letfn [(filtered-user [query] diff --git a/test/metabase/lib/drill_thru/fk_filter_test.cljc b/test/metabase/lib/drill_thru/fk_filter_test.cljc index bc7c272cc40e..77758772e26b 100644 --- a/test/metabase/lib/drill_thru/fk_filter_test.cljc +++ b/test/metabase/lib/drill_thru/fk_filter_test.cljc @@ -173,7 +173,6 @@ :expected-native {:stages [{:filters [[:= {} [:field {} "PRODUCT_ID"] (get-in lib.drill-thru.tu/test-queries ["ORDERS" :aggregated :row "PRODUCT_ID"])]]}]}}))) - (testing "adds an is-null filter for NULL FK values" (let [row (get-in lib.drill-thru.tu/test-queries ["ORDERS" :unaggregated :row])] (lib.drill-thru.tu/test-drill-application diff --git a/test/metabase/lib/drill_thru/pivot_test.cljc b/test/metabase/lib/drill_thru/pivot_test.cljc index d9518ed7b2f0..a1d0d5835055 100644 --- a/test/metabase/lib/drill_thru/pivot_test.cljc +++ b/test/metabase/lib/drill_thru/pivot_test.cljc @@ -26,10 +26,10 @@ :drill-thru/pivot (fn [_test-case _context {:keys [click]}] (if (= click :cell) - ;; The pivot conditions are too complex to capture here; other tests check them. - ;; Just skip the canned cases for cell clicks. + ;; The pivot conditions are too complex to capture here; other tests check them. + ;; Just skip the canned cases for cell clicks. ::canned/skip - ;; Non-cell clicks are false though. + ;; Non-cell clicks are false though. false))))) (def ^:private orders-date-only-test-case diff --git a/test/metabase/lib/drill_thru/pk_test.cljc b/test/metabase/lib/drill_thru/pk_test.cljc index 4d495fcc2829..5611ed932c3a 100644 --- a/test/metabase/lib/drill_thru/pk_test.cljc +++ b/test/metabase/lib/drill_thru/pk_test.cljc @@ -25,8 +25,8 @@ (canned/canned-test :drill-thru/pk (fn [_test-case _context {:keys [click]}] - ;; Tricky logic, so other tests check the cell clicks. - ;; Non-cell clicks are not available. + ;; Tricky logic, so other tests check the cell clicks. + ;; Non-cell clicks are not available. (if (= click :cell) ::canned/skip false)))) @@ -41,7 +41,6 @@ :column-ref (lib/ref (meta/field-metadata :orders :id)) :value nil}]}] (is (not (find-drill query context))) - (testing "but a nil clicked value with defined PKs is fine" (is (find-drill query {:column (meta/field-metadata :orders :subtotal) :column-ref (lib/ref (meta/field-metadata :orders :subtotal)) diff --git a/test/metabase/lib/drill_thru/test_util/canned.cljc b/test/metabase/lib/drill_thru/test_util/canned.cljc index bc88e285e1a1..94a974a0057e 100644 --- a/test/metabase/lib/drill_thru/test_util/canned.cljc +++ b/test/metabase/lib/drill_thru/test_util/canned.cljc @@ -271,64 +271,47 @@ (click tc :cell "PRODUCT_ID" :basic :fk) (click tc :cell "SUBTOTAL" :basic :number) (click tc :cell "CREATED_AT" :basic :datetime) - (click tc :header "ID" :basic :pk) (click tc :header "PRODUCT_ID" :basic :fk) (click tc :header "SUBTOTAL" :basic :number) (click tc :header "CREATED_AT" :basic :datetime)]) - ;; Singular aggregation for Orders, just clicking that single cell. [(click (test-case metadata-provider :test.query/orders-count) :cell "count" :aggregation :number)] - ;; Breakout-only for Orders by Product ID - click both cell and header. (let [tc (test-case metadata-provider :test.query/orders-by-product-id)] [(click tc :cell "PRODUCT_ID" :breakout :fk) - (click tc :header "PRODUCT_ID" :breakout :fk)]) - ;; Count broken out by Product ID - click both count and Product ID, both the cells and headers; also a pivot. (let [tc (test-case metadata-provider :test.query/orders-count-by-product-id)] [(click tc :cell "count" :aggregation :number) (click tc :cell "PRODUCT_ID" :breakout :fk) - (click tc :header "count" :aggregation :number) (click tc :header "PRODUCT_ID" :breakout :fk) - (click tc :pivot nil :basic :number)]) - ;; Count broken out by Created At - click both count and Created At, both the cells and headers; also a pivot. (let [tc (test-case metadata-provider :test.query/orders-count-by-created-at)] [(click tc :cell "count" :aggregation :number) (click tc :cell "CREATED_AT" :breakout :datetime) - (click tc :header "count" :aggregation :number) (click tc :header "CREATED_AT" :breakout :datetime) - (click tc :pivot nil :basic :number)]) - ;; SUM(Subtotal) broken out by Product ID - same as the count case above. (let [tc (test-case metadata-provider :test.query/orders-sum-subtotal-by-product-id)] [(click tc :cell "sum" :aggregation :number) (click tc :cell "PRODUCT_ID" :breakout :fk) - (click tc :header "sum" :aggregation :number) (click tc :header "PRODUCT_ID" :breakout :fk) - (click tc :pivot nil :basic :number)]) - ;; Count broken out by both Created At and Product.CATEGORY ;; Click all three cells and headers, also a legend click on a category. (let [tc (test-case metadata-provider :test.query/orders-count-by-created-at-and-product-category)] [(click tc :cell "count" :aggregation :number) (click tc :cell "CREATED_AT" :breakout :datetime) (click tc :cell "CATEGORY" :breakout :string) - (click tc :header "count" :aggregation :number) (click tc :header "CREATED_AT" :breakout :datetime) (click tc :header "CATEGORY" :breakout :string) - (click tc :legend "CATEGORY" :breakout :string)]) - ;; Simple query against Products. (let [tc (test-case metadata-provider :test.query/products)] [(click tc :cell "ID" :basic :pk) @@ -337,14 +320,12 @@ (click tc :cell "PRICE" :basic :number) (click tc :cell "RATING" :basic :number) (click tc :cell "CREATED_AT" :basic :datetime) - (click tc :header "ID" :basic :pk) (click tc :header "EAN" :basic :string) (click tc :header "TITLE" :basic :string) (click tc :header "PRICE" :basic :number) (click tc :header "RATING" :basic :number) (click tc :header "CREATED_AT" :basic :datetime)]) - ;; Native query against products (let [tc (test-case metadata-provider :test.query/products-native)] [(click tc :cell "ID" :basic :pk) @@ -353,14 +334,12 @@ (click tc :cell "PRICE" :basic :number) (click tc :cell "RATING" :basic :number) (click tc :cell "CREATED_AT" :basic :datetime) - (click tc :header "ID" :basic :pk) (click tc :header "EAN" :basic :string) (click tc :header "TITLE" :basic :string) (click tc :header "PRICE" :basic :number) (click tc :header "RATING" :basic :number) (click tc :header "CREATED_AT" :basic :datetime)]) - ;; Simple query against Reviews. ;; This one has a :type/Description column (BODY) which matters for Distribution drills. (let [tc (test-case metadata-provider :test.query/reviews)] @@ -370,14 +349,12 @@ (click tc :cell "RATING" :basic :number) (click tc :cell "PRODUCT_ID" :basic :fk) (click tc :cell "CREATED_AT" :basic :datetime) - (click tc :header "ID" :basic :pk) (click tc :header "REVIEWER" :basic :string) (click tc :header "BODY" :basic :string) (click tc :header "RATING" :basic :number) (click tc :header "PRODUCT_ID" :basic :fk) (click tc :header "CREATED_AT" :basic :datetime)]) - ;; Simple query against People. ;; This one has a :type/Email (EMAIL) for Column Extract drills. (let [tc (test-case metadata-provider :test.query/people)] @@ -394,7 +371,6 @@ (click tc :cell "SOURCE" :basic :string) (click tc :cell "BIRTH_DATE" :basic :datetime) (click tc :cell "CREATED_AT" :basic :datetime) - (click tc :header "ID" :basic :pk) (click tc :header "ADDRESS" :basic :string) (click tc :header "EMAIL" :basic :string) @@ -408,7 +384,6 @@ (click tc :header "SOURCE" :basic :string) (click tc :header "BIRTH_DATE" :basic :datetime) (click tc :header "CREATED_AT" :basic :datetime)]) - ;; Simple query against Products, but it lies! ;; Claims VENDOR is :type/SerializedJSON (derives from :type/Structured). (let [tc (-> metadata-provider @@ -418,7 +393,6 @@ (test-case :test.query/products))] [(click tc :cell "VENDOR" :basic :string) (click tc :header "VENDOR" :basic :string)]) - ;; Simple query against People, but it lies! ;; Claims EMAIL is :type/URL (relevant to Column Extract drills). (let [tc (-> metadata-provider diff --git a/test/metabase/lib/drill_thru/underlying_records_test.cljc b/test/metabase/lib/drill_thru/underlying_records_test.cljc index cd2a0a03c2ac..6f2ec9678d60 100644 --- a/test/metabase/lib/drill_thru/underlying_records_test.cljc +++ b/test/metabase/lib/drill_thru/underlying_records_test.cljc @@ -28,15 +28,15 @@ (canned/canned-test :drill-thru/underlying-records (fn [test-case context {:keys [click column-kind]}] - ;; TODO: The docs claim that underlying-records works on pivot cells, and so it does, but the so-called pivot case - ;; never occurs in actual pivot tables! - ;; - Clicks on row/column "headers", (that is, breakout values like a month or product category) look like regular - ;; cell clicks (column and value set per the breakout, no :dimensions). - ;; - Clicks on cells (that is, aggregation values) have column, column-ref and value all nil, and :dimensions - ;; contains all the breakouts (not exactly 2 as claimed in the spec). - ;; That all makes sense to me (Braden) and I think this is a bug in the docs, but it also might be a bug in the FE - ;; code that should be setting the aggregation :value for cell clicks? - ;; Tech debt issue: #39380 + ;; TODO: The docs claim that underlying-records works on pivot cells, and so it does, but the so-called pivot case + ;; never occurs in actual pivot tables! + ;; - Clicks on row/column "headers", (that is, breakout values like a month or product category) look like regular + ;; cell clicks (column and value set per the breakout, no :dimensions). + ;; - Clicks on cells (that is, aggregation values) have column, column-ref and value all nil, and :dimensions + ;; contains all the breakouts (not exactly 2 as claimed in the spec). + ;; That all makes sense to me (Braden) and I think this is a bug in the docs, but it also might be a bug in the FE + ;; code that should be setting the aggregation :value for cell clicks? + ;; Tech debt issue: #39380 (and (#{:cell #_:pivot :legend} click) (not (:native? test-case)) (or (seq (:dimensions context)) diff --git a/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc b/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc index 43f219f4579f..8beb8e28b5ff 100644 --- a/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc +++ b/test/metabase/lib/drill_thru/zoom_in_bins_test.cljc @@ -170,7 +170,7 @@ :custom-row {"count" 100 "QUANTITY" 10 "CREATED_AT" "2024-09-08T22:03:20.239+03:00"} - ;; TODO: Clicking on breakout columns in table views doesn't work properly. + ;; TODO: Clicking on breakout columns in table views doesn't work properly. :column-name "count" :drill-type :drill-thru/zoom-in.binning :expected {:type :drill-thru/zoom-in.binning @@ -216,7 +216,6 @@ drilled (lib/drill-thru query -1 nil zoom-in)] (testing "zoom-in.binning is available" (is (some? zoom-in))) - (testing "drilled query" (testing "still has both breakouts" (is (= 2 (count (lib/breakouts drilled))))) @@ -246,7 +245,6 @@ drilled (lib/drill-thru query -1 nil zoom-in)] (testing "zoom-in.binning is available" (is (some? zoom-in))) - (testing "drilled query" (testing "still has both breakouts" (is (= 2 (count (lib/breakouts drilled))))) @@ -401,11 +399,9 @@ ;; the column (as printed out in console.log) was from legacy metadata, and had `:source-alias`, renamed to ;; `:lib/original-join-alias`; but should be missing `:lib/join-alias` (assert (:lib/original-join-alias people-orders-clicked-column)) - (assert (nil? (get-in orders-people-ref [1 :join-alias]))) (assert (nil? (:lib/join-alias orders-people-clicked-column))) (assert (nil? (:source-alias orders-people-clicked-column))) - (testing "zoom-in binning should not depend on join order" (is (= (assoc-in orders-people-zoom [:column :lib/original-join-alias] "Orders") people-orders-zoom))))) diff --git a/test/metabase/lib/drill_thru/zoom_in_geographic_test.cljc b/test/metabase/lib/drill_thru/zoom_in_geographic_test.cljc index 0bffa0ea3d65..1aaeaa1480ef 100644 --- a/test/metabase/lib/drill_thru/zoom_in_geographic_test.cljc +++ b/test/metabase/lib/drill_thru/zoom_in_geographic_test.cljc @@ -38,13 +38,11 @@ :filters [[:= {} [:field {} field-key] "United States"]]}]}] - (testing "sanity check: make sure COUNTRY has :type/Country semantic type" (testing `lib/returned-columns (let [[country _count] (lib/returned-columns query)] (is (=? {:semantic-type :type/Country} country))))) - (lib.drill-thru.tu/test-drill-variants-with-merged-args lib.drill-thru.tu/test-drill-application "single-stage query" @@ -68,7 +66,6 @@ "mutli-stage query" {:custom-query (lib.drill-thru.tu/append-filter-stage query "count") :expected-query (lib.drill-thru.tu/append-filter-stage-to-test-expectation expected-query "count")}) - (testing "nil breakout value means we can't zoom in" (lib.drill-thru.tu/test-drill-variants-with-merged-args lib.drill-thru.tu/test-drill-not-returned @@ -124,7 +121,6 @@ "multi-stage query" {:custom-query (lib.drill-thru.tu/append-filter-stage query "count") :expected-query (lib.drill-thru.tu/append-filter-stage-to-test-expectation expected-query "count")}) - (testing "nil breakout value means we can't zoom in" (lib.drill-thru.tu/test-drill-variants-with-merged-args lib.drill-thru.tu/test-drill-not-returned @@ -144,7 +140,7 @@ (testing "If there are already breakouts on lat/lon, we should update them rather than append new ones (#34874)" (let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :people)) (lib/aggregate (lib/count)) - ;; make sure we don't remove other, irrelevant breakouts. + ;; make sure we don't remove other, irrelevant breakouts. (lib/breakout (meta/field-metadata :people :name)) (lib/breakout (meta/field-metadata :people :state)) (lib/breakout (-> (meta/field-metadata :people :latitude) @@ -230,7 +226,6 @@ "multi-stage query" {:custom-query (lib.drill-thru.tu/append-filter-stage query "count") :expected-query (lib.drill-thru.tu/append-filter-stage-to-test-expectation expected-query "count")}) - (testing "nil breakout value means we can't zoom in" (lib.drill-thru.tu/test-drill-variants-with-merged-args lib.drill-thru.tu/test-drill-not-returned diff --git a/test/metabase/lib/drill_thru/zoom_test.cljc b/test/metabase/lib/drill_thru/zoom_test.cljc index 87fbbe5afe42..9e828283d9a0 100644 --- a/test/metabase/lib/drill_thru/zoom_test.cljc +++ b/test/metabase/lib/drill_thru/zoom_test.cljc @@ -16,14 +16,14 @@ (fn [test-case {:keys [value]} {:keys [click column-type]}] (and (= click :cell) (not (:native? test-case)) - ;; With an FK column and a non-NULL value, this will be an fk-filter drill instead. - ;; So we don't expect a zoom drill in that case. + ;; With an FK column and a non-NULL value, this will be an fk-filter drill instead. + ;; So we don't expect a zoom drill in that case. (not (and (= column-type :fk) (some? value) (not= value :null))) - ;; PK must be in the result set; if not, no zoom drill. This happens for eg. aggregations. + ;; PK must be in the result set; if not, no zoom drill. This happens for eg. aggregations. ((set (keys (:row test-case))) "ID") - ;; Special case: clicking a NULL PK does not return the zoom drill. + ;; Special case: clicking a NULL PK does not return the zoom drill. (not (and (= value :null) (= column-type :pk)))))))) diff --git a/test/metabase/lib/equality_test.cljc b/test/metabase/lib/equality_test.cljc index 1c719839f9f0..b01f4583b9f7 100644 --- a/test/metabase/lib/equality_test.cljc +++ b/test/metabase/lib/equality_test.cljc @@ -632,7 +632,6 @@ (testing "different columns" (is (int? (:id created-at))) (is (nil? (:id ca-expr)))) - (testing "both refs should match correctly" (is (= created-at (lib.equality/find-matching-column (lib/ref created-at) columns))) diff --git a/test/metabase/lib/expression_test.cljc b/test/metabase/lib/expression_test.cljc index b9f2adaff7b6..41902afb6d04 100644 --- a/test/metabase/lib/expression_test.cljc +++ b/test/metabase/lib/expression_test.cljc @@ -414,7 +414,6 @@ 3]] (lib/expressions query))) (is (= 1 (count (lib/joins query)))) - (let [dropped (lib/remove-join query join)] (is (empty? (lib/joins dropped))) (is (empty? (lib/expressions dropped))))))) diff --git a/test/metabase/lib/fe_util_test.cljc b/test/metabase/lib/fe_util_test.cljc index 39df17df9f55..8acd71bf9a7b 100644 --- a/test/metabase/lib/fe_util_test.cljc +++ b/test/metabase/lib/fe_util_test.cljc @@ -229,7 +229,6 @@ :lib/expression-name expression-name :lib/source :source/expressions} (lib/expression-parts query stage-number [:expression {:base-type :type/Integer} expression-name]))))) - (testing "nested expression references" (mu/disable-enforcement (is (=? {:lib/type :metadata/column @@ -306,7 +305,6 @@ (is (=? operator (:operator res))) (is (=? options (:options res))) (is (=? (map :id args) (map :id (:args res))))))) - (testing "case pairs should be unflattened in expression clause" (doseq [[expected-expression parts] test-cases] (let [{:keys [operator options args]} parts @@ -315,7 +313,6 @@ [actual-op _ actual-args] actual-expression] (is (=? expected-op actual-op)) (is (=? (map :id expected-args) (map :id actual-args)))))) - (testing "case/if should round-trip through expression-parts and expression-clause" (doseq [[clause] test-cases] (let [parts (lib.fe-util/expression-parts query clause) @@ -324,7 +321,6 @@ (:args parts) nil) round-tripped-parts (lib.fe-util/expression-parts query round-tripped-expression)] - (is (=? (:operator parts) (:operator round-tripped-parts))) (is (=? (map :id (:args parts)) (map :id (:args round-tripped-parts))))))))) @@ -339,7 +335,6 @@ :args [boolean-field string-field "default"]} - {:lib/type :mbql/expression-parts :operator :upper :options {} @@ -354,7 +349,6 @@ :args [boolean-field string-field "default"]}]}]} - {:lib/type :mbql/expression-parts :operator :upper :options {} @@ -365,7 +359,6 @@ :operator :case :options {} :args [boolean-field string-field]}]}]}]] - (let [expression (lib.fe-util/expression-clause (:operator parts) (:args parts) diff --git a/test/metabase/lib/field_test.cljc b/test/metabase/lib/field_test.cljc index d3ad386fa794..25c58fbea16d 100644 --- a/test/metabase/lib/field_test.cljc +++ b/test/metabase/lib/field_test.cljc @@ -665,7 +665,6 @@ (lib.binning/with-binning field-metadata) lib.binning/binning (lib/display-info query))))))) - (testing "coordinate binning" (let [query (lib/query meta/metadata-provider (meta/table-metadata :people)) field-metadata (meta/field-metadata :people :latitude) @@ -929,7 +928,6 @@ (-> query (#'lib.field/populate-fields-for-stage -1) fields-of))))) - (testing "explicit join fields are *not* included" (let [query (as-> (meta/table-metadata :orders) <> (lib/query meta/metadata-provider <>) @@ -957,7 +955,6 @@ (lib.util/update-query-stage -1 update-in [:joins 0] lib/with-join-fields (take 3 returned)) (#'lib.field/populate-fields-for-stage -1) fields-of))))))) - (testing "sourced from another card" (let [query (lib.tu/query-with-source-card)] (testing "starts with no :fields" @@ -1059,7 +1056,6 @@ lib/returned-columns (map lib/ref) sorted-fields)))))) - (testing "join :fields list" (let [join-fields-query (lib.util/update-query-stage query -1 @@ -1462,18 +1458,15 @@ implied2 (clean-ref (second implicit-columns))] (is (= (map #(dissoc % :selected?) table-columns) (lib/returned-columns query))) - (testing "attaching implicitly joined fields should alter the query" (is (not= query implied-query)) (is (nil? (lib.equality/find-matching-ref (first implicit-columns) (map lib/ref (lib/returned-columns query)))))) - (testing "with no :fields set does nothing" (is (=? query (lib/remove-field query -1 (first implicit-columns)))) (is (=? query (lib/remove-field query -1 (second implicit-columns))))) - (testing "with explicit :fields list" (is (=? (sorted-fields (conj table-fields implied2)) (-> implied-query @@ -1532,7 +1525,6 @@ (-> query (#'lib.field/populate-fields-for-stage 1) fields-of)))) - (testing "removing each field" (is (=? [[:field {} "CREATED_AT"]] (-> query @@ -1542,7 +1534,6 @@ (-> query (lib/remove-field 1 created-at) fields-of)))) - (testing "removing and adding each field" (is (nil? (-> query (lib/remove-field 1 sum) @@ -1574,7 +1565,6 @@ (-> query (lib/remove-field 1 (second columns)) fields-of)))) - (testing "removing and adding each field" (is (nil? (-> query (lib/remove-field 1 (first columns)) @@ -1718,7 +1708,6 @@ (filter :selected? (lib.equality/mark-selected-columns (lib.metadata.calculation/visible-columns hidden) (lib.metadata.calculation/returned-columns hidden))))) - (testing "and showing it again" (let [shown (lib/add-field query -1 to-hide)] (is (=? exp-shown @@ -1840,7 +1829,6 @@ (testing "can add an implicit join" (is (= (inc (count (lib.metadata.calculation/returned-columns query))) (count (lib.metadata.calculation/returned-columns joined))))) - (testing "correctly marks columns as selected" (testing "without the implicit join" (is (not (-> query mark-selected get-state :selected?)))) diff --git a/test/metabase/lib/filter/desugar/jvm_test.clj b/test/metabase/lib/filter/desugar/jvm_test.clj index a331432f9545..f9b93951e68c 100644 --- a/test/metabase/lib/filter/desugar/jvm_test.clj +++ b/test/metabase/lib/filter/desugar/jvm_test.clj @@ -56,14 +56,14 @@ (deftest ^:parallel subdomain-regex-on-urls-test (are [subdomain url] (= subdomain (re-find @#'lib.filter.desugar.jvm/subdomain-regex url)) - ;; Blanks. "www" doesn't count. + ;; Blanks. "www" doesn't count. nil "cdbaby.com" nil "https://fema.gov" nil "http://www.geocities.jp" nil "usa.gov/some/page.cgi.htm" nil "va.gov" - ;; Basics - taking the first segment that isn't "www", IF it isn't the domain. + ;; Basics - taking the first segment that isn't "www", IF it isn't the domain. "sub" "sub.jalbum.net" "subdomains" "subdomains.go.here.jalbum.net" "log" "log.stuff.gmpg.org" @@ -71,22 +71,22 @@ "log" "log.stuff.gmpg.org/some/path" "log" "log.stuff.gmpg.org?search=yes" - ;; Oops, we miss these! This is the reverse of the problem when picking the domain. - ;; We can't tell without maintaining a huge list that va and ne are the real domains, and not the trailing - ;; fragments like .co.uk - see below. + ;; Oops, we miss these! This is the reverse of the problem when picking the domain. + ;; We can't tell without maintaining a huge list that va and ne are the real domains, and not the trailing + ;; fragments like .co.uk - see below. nil "taxes.va.gov" ; True domain is va, subdomain is taxes. nil "hatena.ne.jp" ; True domain is ne, subdomain is hatena. - ;; Sometimes the second-last part is a short suffix. - ;; Mozilla maintains a huge list of these, but since this has to go into a regex and get passed to the database, - ;; we use a best-effort matcher that gets the domain right most of the time. + ;; Sometimes the second-last part is a short suffix. + ;; Mozilla maintains a huge list of these, but since this has to go into a regex and get passed to the database, + ;; we use a best-effort matcher that gets the domain right most of the time. nil "telegraph.co.uk" nil "https://telegraph.co.uk" nil "telegraph.co.uk/some/article.php" "local" "local.news.telegraph.co.uk" nil "bbc.co.uk#fragment" "video" "video.bbc.co.uk" - ;; "www" is disregarded as a possible subdomain. + ;; "www" is disregarded as a possible subdomain. nil "www.usa.gov" nil "www.dot.va.gov" "licensing" "www.licensing.dot.va.gov")) diff --git a/test/metabase/lib/filter_test.cljc b/test/metabase/lib/filter_test.cljc index d87755044fdc..b9ede2ef4fe7 100644 --- a/test/metabase/lib/filter_test.cljc +++ b/test/metabase/lib/filter_test.cljc @@ -46,7 +46,6 @@ f venues-category-id-metadata categories-id-metadata))) - (testing "between" (test-clause [:between @@ -61,7 +60,6 @@ venues-category-id-metadata 42 categories-id-metadata)) - (testing "inside" (test-clause [:inside @@ -73,7 +71,6 @@ venues-latitude-metadata venues-longitude-metadata 42.7 13 4 27.3)) - (testing "emptiness" (doseq [[op f] [[:is-null lib/is-null] [:not-null lib/not-null] @@ -85,7 +82,6 @@ [:field {:lib/uuid string?} (meta/id :venues :name)]] f venues-name-metadata))) - (testing "string tests" (doseq [[op f] [[:starts-with lib/starts-with] [:ends-with lib/ends-with] @@ -99,7 +95,6 @@ f venues-name-metadata "part"))) - (testing "time-interval" (test-clause [:time-interval @@ -111,7 +106,6 @@ checkins-date-metadata 3 :day)) - (testing "segment" (let [id 7] (test-clause @@ -137,7 +131,6 @@ :filters [original-filter]}]}] (testing "no filter" (is (nil? (lib/filters q2)))) - (testing "setting a simple filter via the helper function" (let [result-query (lib/filter q1 (lib/between venues-category-id-metadata 42 100)) @@ -147,7 +140,6 @@ (testing "and getting the current filter" (is (=? [result-filter] (lib/filters result-query)))))) - (testing "setting a simple filter expression" (is (=? simple-filtered-query (-> q1 @@ -354,7 +346,6 @@ :name "Created At excludes 3 hour of day selections"} {:clause [:not-in (lib/get-hour created-at) 0 1 2] :name "Created At excludes 3 hour of day selections"} - {:clause [:= (created-at-with :day-of-week) "2023-10-02"] :name "Created At: Day of week is Monday"} {:clause [:= (lib.expression/get-day-of-week created-at :iso) 1] @@ -373,7 +364,6 @@ :name "Created At excludes 3 day of week selections"} {:clause [:not-in (lib.expression/get-day-of-week created-at :iso) 1 2 3] :name "Created At excludes 3 day of week selections"} - {:clause [:= (created-at-with :month-of-year) "2023-01-01"] :name "Created At: Month of year is on Jan"} {:clause [:= (lib/get-month created-at) 1] @@ -392,7 +382,6 @@ :name "Created At excludes 3 month of year selections"} {:clause [:not-in (lib/get-month created-at) 1 2 3] :name "Created At excludes 3 month of year selections"} - {:clause [:= (created-at-with :quarter-of-year) "2023-01-03"] :name "Created At: Quarter of year is on Q1"} {:clause [:= (lib/get-quarter created-at) 1] @@ -411,7 +400,6 @@ :name "Created At excludes 3 quarter of year selections"} {:clause [:not-in (lib/get-quarter created-at) 1 2 3] :name "Created At excludes 3 quarter of year selections"} - {:clause [:= (lib/get-year created-at) 2001] :name "Created At is in 2001"} {:clause [:= (lib/get-year created-at) 2001 2002 2003] @@ -422,7 +410,6 @@ :name "Created At excludes 3 year of era selections"} {:clause [:not-in (lib/get-year created-at) 2001 2002 2003] :name "Created At excludes 3 year of era selections"} - {:clause [:is-null created-at] :name "Created At is empty"} {:clause [:not-null created-at] diff --git a/test/metabase/lib/join_test.cljc b/test/metabase/lib/join_test.cljc index fa1f352f08f0..669fe900c8ae 100644 --- a/test/metabase/lib/join_test.cljc +++ b/test/metabase/lib/join_test.cljc @@ -174,7 +174,6 @@ venues-category-id-metadata (meta/field-metadata :venues :category-id) categories-id-metadata (m/find-first #(= (:id %) (meta/id :categories :id)) (lib/visible-columns q2))] - (let [clause (lib/join-clause q2 [(lib/= categories-id-metadata venues-category-id-metadata)])] (is (=? {:lib/type :mbql/join :lib/options {:lib/uuid string?} @@ -1944,7 +1943,6 @@ (testing "DO propagate temporal unit if it is included in join :fields" (let [query (lib/query meta/metadata-provider - (lib.tu.macros/mbql-query people {:source-query {:source-table $$people :breakout [!month.created-at] diff --git a/test/metabase/lib/js_test.cljs b/test/metabase/lib/js_test.cljs index 31141e3fe41c..796480dd6179 100644 --- a/test/metabase/lib/js_test.cljs +++ b/test/metabase/lib/js_test.cljs @@ -36,8 +36,8 @@ #js ["field" 5 nil] #js ["field" 6 nil] #js ["field" 7 nil]]}} - ;; Note that the order is not relevant; they get grouped. - ;; Duplicates are okay, and are tracked. + ;; Note that the order is not relevant; they get grouped. + ;; Duplicates are okay, and are tracked. field-ids #js [1 2 6 7 3 5 4 4 4]] (is (not (lib.js/query= q1 q2)) "the field-ids must be provided to populate q1") @@ -115,13 +115,11 @@ (query-with-field-opts #js {"base-type" "type/Text"}))) (is (lib.js/query= (query-with-field-opts #js {"effective-type" "type/Float"}) (query-with-field-opts #js {"effective-type" "type/Float"})))) - (testing "mismatched field types are not equal" (is (not (lib.js/query= (query-with-field-opts #js {"base-type" "type/Text"}) (query-with-field-opts #js {"base-type" "type/Float"})))) (is (not (lib.js/query= (query-with-field-opts #js {"effective-type" "type/Text"}) (query-with-field-opts #js {"effective-type" "type/Float"}))))) - (testing "missing field types are equal" (is (lib.js/query= (query-with-field-opts #js {"base-type" "type/Text"}) (query-with-field-opts #js {}))) @@ -357,7 +355,6 @@ [:datetime-diff {} [:field {} int?] [:field {} int?] :day] (lib.js/expression-clause "datetime-diff" [(meta/field-metadata :products :created-at) (meta/field-metadata :products :created-at) "day"] nil)) - (testing "normalizes recursively" (is (=? [:time-interval {} [:field {} int?] @@ -400,18 +397,17 @@ true true false false - ;; Objects + ;; Objects #js {:foo "bar"} #js {:foo "bar"} #js {:foo "bar", :baz "quux"} #js {:foo "bar", :baz "quux"} - ;; Arrays + ;; Arrays #js ["foo" #js [1 2 3]] #js ["foo" #js [1 2 3]] - ;; Nesting + ;; Nesting #js [#js {:foo "bar", :baz #js [4 5]}, #js [1 2 3]] #js [#js {:foo "bar", :baz #js [4 5]}, #js [1 2 3]])) - (testing "should be false" (are [a b] (= false (js= a b)) 7 8 @@ -422,19 +418,19 @@ true false false 7 - ;; Objects + ;; Objects #js {:foo "bar"} #js {:foo "baz"} ; Different value #js {:foo "bar"} #js {} ; Missing an a key in b #js {} #js {:foo "bar"} ; Missing a b key in a #js {:foo nil} #js {} ; Missing is not the same as present-but-nil #js {} #js {:foo nil} ; And likewise in reverse - ;; Arrays + ;; Arrays #js ["foo" "bar"] #js ["foo" "baz"] ; Different values #js ["foo" "bar"] #js ["foo"] ; Different lengths #js ["foo"] #js ["foo" "bar"] - ;; Nesting + ;; Nesting #js [#js {:foo "bar", :baz #js [4 5 6]}, #js [1 2 3]] #js [#js {:foo "bar", :baz #js [4 5]}, #js [1 2 3]])))) @@ -445,11 +441,9 @@ (testing "description is present in the display-info for a column" (is (= (:description discount) (.-description (lib.js/display-info query -1 discount)))) - (testing "but if missing from the input, it's missing from the display-info" (let [di (lib.js/display-info query -1 (dissoc discount :fingerprint))] (is (not (gobject/containsKey di "description")))))) - (testing "fingerprint is included in display-info" (let [query (lib/query meta/metadata-provider (meta/table-metadata :orders)) by-dca (m/index-by :lib/desired-column-alias @@ -552,13 +546,11 @@ (let [obj (lib.js/as-returned simple-query stage nil)] (is (=? simple-query (.-query obj))) (is (=? stage (.-stageIndex obj))))) - (testing "in the target stage" (doseq [stage [1 -1]] (let [obj (lib.js/as-returned two-stage stage nil)] (is (=? two-stage (.-query obj))) (is (=? stage (.-stageIndex obj))))))) - (testing "uses an existing later stage if it exists" (let [obj (lib.js/as-returned two-stage 0 nil)] (is (=? two-stage (.-query obj))) @@ -566,13 +558,11 @@ (let [obj (lib.js/as-returned two-stage-agg 0 nil)] (is (=? two-stage-agg (.-query obj))) (is (=? 1 (.-stageIndex obj))))) - (testing "appends a new stage if necessary" (let [obj (lib.js/as-returned two-stage-agg 1 nil)] (is (=? (lib/append-stage two-stage-agg) (.-query obj))) (is (=? -1 (.-stageIndex obj))))) - (testing "only breakouts" (let [brk-only (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/breakout (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :month))) @@ -588,7 +578,6 @@ (is (=? (lib/append-stage brk-only) (.-query obj))) (is (=? -1 (.-stageIndex obj))))))) - (testing "only aggregations" (let [agg-only (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/aggregate (lib/count))) diff --git a/test/metabase/lib/metadata/calculation_test.cljc b/test/metabase/lib/metadata/calculation_test.cljc index 56144a940608..130943a55ecc 100644 --- a/test/metabase/lib/metadata/calculation_test.cljc +++ b/test/metabase/lib/metadata/calculation_test.cljc @@ -40,7 +40,6 @@ (map (comp :long-display-name #(lib/display-info query 0 %))))] (is (= ["ID" "Name" "Category ID" "Latitude" "Longitude" "Price" "Category → ID" "Category → Name"] results))) - (let [query (lib/query meta/metadata-provider (meta/table-metadata :orders)) results (->> query lib/visible-columns @@ -289,14 +288,12 @@ (is (= [] (->> (lib/visible-columns query) (remove (comp #{:source/card} :lib/source))))))) - (testing "metadata for the FK target field is not sufficient" (let [query (query-with-user-id-tweaks {:fk-target-field-id (meta/id :people :id)})] (is (= 9 (count (lib/visible-columns query)))) (is (= [] (->> (lib/visible-columns query) (remove (comp #{:source/card} :lib/source))))))) - (testing "an ID for the FK field itself is not sufficient" (let [query (query-with-user-id-tweaks {:id (meta/id :orders :user-id) :semantic-type nil})] @@ -959,7 +956,6 @@ (testing "temporal unit should not be incorrectly propagated in returned-columns past the stage where the bucketing was done" (let [query (lib/query meta/metadata-provider - (lib.tu.macros/mbql-query people {:source-query {:source-table $$people :breakout [!month.created-at] diff --git a/test/metabase/lib/metadata/result_metadata_test.cljc b/test/metabase/lib/metadata/result_metadata_test.cljc index c38eae5e822e..4eb5084f2bca 100644 --- a/test/metabase/lib/metadata/result_metadata_test.cljc +++ b/test/metabase/lib/metadata/result_metadata_test.cljc @@ -857,9 +857,9 @@ {:name "a", :base-type :type/*, :effective-type :type/*} {:name "a", :base-type :type/Integer}] :let [expected-base-type (if (= (:base-type initial-metadata) :type/Integer) - ;; if the initial driver type comes back as something other than `:type/*`, we - ;; should use that. Otherwise if it comes back as `:type/*` use the type - ;; calculated by Lib. + ;; if the initial driver type comes back as something other than `:type/*`, we + ;; should use that. Otherwise if it comes back as `:type/*` use the type + ;; calculated by Lib. :type/Integer :type/BigInteger)]] ;; should work with and without rows diff --git a/test/metabase/lib/native_test.cljc b/test/metabase/lib/native_test.cljc index f4fc4e7283e6..b5f82d630f5d 100644 --- a/test/metabase/lib/native_test.cljc +++ b/test/metabase/lib/native_test.cljc @@ -56,7 +56,6 @@ :snippet-id 1 :id string?}} (lib.native/extract-template-tags metadata-provider "SELECT * FROM {{snippet: foo}} WHERE {{snippet:foo}}"))))) - (testing "renaming a variable" (let [old-tag {:type :text :name "foo" @@ -76,7 +75,6 @@ :id (:id old-tag)}} (lib.native/extract-template-tags meta/metadata-provider "SELECT * FROM {{bar}}" {"foo" (assoc old-tag :display-name "Custom Name")})))) - (testing "works with other variables present, if they don't change" (let [other {:type :text :name "other" @@ -90,7 +88,6 @@ (lib.native/extract-template-tags meta/metadata-provider "SELECT * FROM {{bar}} AND field = {{other}}" {"foo" old-tag "other" other}))))))) - (testing "general case, add and remove" (let [mktag (fn [base] (merge {:type :text @@ -607,7 +604,6 @@ :name "mytag" :display-name "My Tag" :id "9ae1ea5e-ac33-4574-bc95-ff595b0ac1a7"}})) - (testing "invalid template tags should return the correct errors" (mu/disable-enforcement (are diff --git a/test/metabase/lib/order_by_test.cljc b/test/metabase/lib/order_by_test.cljc index c0396cf722b2..11b072f2c0a7 100644 --- a/test/metabase/lib/order_by_test.cljc +++ b/test/metabase/lib/order_by_test.cljc @@ -109,7 +109,6 @@ (lib/order-by (meta/field-metadata :venues :id)) (lib/order-by (meta/field-metadata :venues :price)) lib/order-bys)))) - (testing "Should be able to order by two distinct expressions" (is (=? [[:asc {:lib/uuid string?} diff --git a/test/metabase/lib/parameters/parse_test.cljc b/test/metabase/lib/parameters/parse_test.cljc index 353742e4039f..8ad51d89d495 100644 --- a/test/metabase/lib/parameters/parse_test.cljc +++ b/test/metabase/lib/parameters/parse_test.cljc @@ -123,7 +123,6 @@ (is (= expected (normalize-tokens (params.parse/parse s))) (lib.util/format "%s should get parsed to %s" (pr-str s) (pr-str expected)))))) - (testing "Testing that invalid/unterminated template lib.parms.parse.types/clauses throw an exception" (doseq [invalid ["select * from foo [[where bar = {{baz}} " "select * from foo [[where bar = {{baz]]" @@ -139,7 +138,6 @@ (testing "SQL comments are ignored when handle-sql-comments = false, e.g. in Mongo driver queries" (doseq [[query result] [["{{{foo}}: -- {{bar}}}" ["{" (param "foo") ": -- " (param "bar") "}"]] - ["{{{foo}}: \"/* {{bar}} */\"}" ["{" (param "foo") ": \"/* " (param "bar") " */\"}"]]]] (is (= result (normalize-tokens (params.parse/parse query false))))))) diff --git a/test/metabase/lib/query/test_spec_test.cljc b/test/metabase/lib/query/test_spec_test.cljc index a55fd9028a95..879a04bbffce 100644 --- a/test/metabase/lib/query/test_spec_test.cljc +++ b/test/metabase/lib/query/test_spec_test.cljc @@ -45,11 +45,9 @@ :fields [{:type :column :name "ID" :source-name "ORDERS"} - ;; column without source-name can be found if it is unambiguous {:type :column :name "TOTAL"} - ;; implicitly joined column {:type :column :name "NAME" @@ -460,7 +458,6 @@ (is (=? [[:desc {} [:field {:temporal-unit :month} (meta/id :checkins :date)]]] (lib/order-bys query))))) - (testing "test-query adds order-by with temporal bucketing when selecting the second column" (let [query (lib.query.test-spec/test-query meta/metadata-provider @@ -575,7 +572,6 @@ :source-name "PRODUCTS"} :right {:type :column :name "PRODUCT_ID"}}]}]}]})] - (is (=? [{:strategy :left-join :alias "Products"} {:strategy :right-join @@ -615,20 +611,17 @@ :value 42} {:type :column :name "PRICE"}]}}]}]})] - (is (=? [[:= {} "Gadget" [:field {:source-field (meta/id :orders :product-id)} (meta/id :products :category)]]] (lib/filters query))) - (is (=? [[:sum {} [:field {:source-field (meta/id :orders :product-id)} (meta/id :products :price)]]] (lib/aggregations query))) - (is (=? [[:+ {:lib/expression-name "Custom"} 42 @@ -636,19 +629,16 @@ {:source-field (meta/id :orders :product-id)} (meta/id :products :price)]]] (lib/expressions query))) - (is (=? [[:field {:source-field (meta/id :orders :product-id)} (meta/id :products :created-at)]] (lib/breakouts query))) - (let [query (lib.query.test-spec/test-query meta/metadata-provider {:stages [{:source {:type :table :id (meta/id :orders)} :order-bys [{:type :column :name "PRICE"}]}]})] - (is (=? [[:asc {} [:field {:source-field (meta/id :orders :product-id)} (meta/id :products :price)]]] @@ -766,7 +756,6 @@ :name "CREATED_AT" :source-name "ORDERS" :direction :desc}]} - ;; Stage 1 {:expressions [{:name "doubled-count" :value {:type :operator @@ -786,7 +775,6 @@ :order-bys [{:type :column :name "total-revenue" :direction :asc}]} - ;; Stage 2 {:filters [{:type :operator :operator :< @@ -796,61 +784,46 @@ :value 500}]}] :limit 25}]})] - (is (= 3 (lib/stage-count query))) - ;; Stage 0 (is (=? [{:strategy :left-join :alias "Products"} {:strategy :inner-join :alias "People - User"}] (lib/joins query 0))) - (is (=? [[:* {:lib/expression-name "discounted-price"} [:field {} (meta/id :products :price)] 0.9] [:/ {:lib/expression-name "double-discount"} [:expression {} "discounted-price"] 2]] (lib/expressions query 0))) - (is (empty? (lib/fields query 0))) - (is (=? [[:and {} [:> {} [:field {} (meta/id :orders :total)] 50] [:or {} [:= {} [:field {:join-alias "Products"} (meta/id :products :category)] "Widget"] [:< {} [:expression {} "double-discount"] 10]]]] (lib/filters query 0))) - (is (=? [[:count {:display-name "total-count"}] [:sum {:display-name "total-revenue"} [:field {} (meta/id :orders :total)]] [:avg {} [:field {:join-alias "Products"} (meta/id :products :price)]]] (lib/aggregations query 0))) - (is (=? [[:field {:temporal-unit :month} (meta/id :orders :created-at)] [:field {:binning {:strategy :num-bins :num-bins 10}} (meta/id :orders :quantity)]] (lib/breakouts query 0))) - (is (=? [[:desc {} [:field {:temporal-unit :month} (meta/id :orders :created-at)]]] (lib/order-bys query 0))) - ;; Stage 1 (is (=? [[:* {:lib/expression-name "doubled-count"} [:field {} "total-count"] 2]] (lib/expressions query 1))) - (is (=? [[:> {} [:field {} "total-revenue"] 1000]] (lib/filters query 1))) - (is (=? [[:asc {} [:field {} "total-revenue"]]] (lib/order-bys query 1))) - (is (empty? (lib/fields query 1))) - ;; Stage 2 (is (=? [[:< {} [:field {} "doubled-count"] 500]] (lib/filters query 2))) - (is (empty? (lib/fields query 2))) - (is (= 25 (lib/current-limit query 2)))))) #_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]} @@ -926,7 +899,6 @@ :source-name "PRODUCTS"} {:type :column :name "double-discount"}]} - ;; Stage 1 {:expressions [{:name "half-discount" :value {:type :operator @@ -942,7 +914,6 @@ :name "half-discount"} {:type :literal :value 1000}]}]} - ;; Stage 2 {:filters [{:type :operator :operator :< @@ -952,57 +923,44 @@ :value 500}]}] :limit 25}]})] - (is (= 3 (lib/stage-count query))) - ;; Stage 0 (is (=? [{:strategy :left-join :alias "Products"} {:strategy :inner-join :alias "People - User"}] (lib/joins query 0))) - (is (=? [[:* {:lib/expression-name "discounted-price"} [:field {} (meta/id :products :price)] 0.9] [:/ {:lib/expression-name "double-discount"} [:expression {} "discounted-price"] 2]] (lib/expressions query 0))) - (is (=? [[:field {} (meta/id :orders :total)] [:field {:join-alias "Products"} (meta/id :products :category)] [:expression {} "double-discount"] [:expression {} "discounted-price"]] (lib/fields query 0))) - (is (=? [[:and {} [:> {} [:field {} (meta/id :orders :total)] 50] [:or {} [:= {} [:field {:join-alias "Products"} (meta/id :products :category)] "Widget"] [:< {} [:expression {} "double-discount"] 10]]]] (lib/filters query 0))) - (is (empty? (lib/aggregations query 0))) (is (empty? (lib/breakouts query 0))) - (is (=? [[:desc {} [:field {} (meta/id :orders :created-at)]]] (lib/order-bys query 0))) - ;; Stage 1 (is (=? [[:/ {:lib/expression-name "half-discount"} [:field {} "double-discount"] 4]] (lib/expressions query 1))) - (is (=? [[:> {} [:expression {} "half-discount"] 1000]] (lib/filters query 1))) - (is (empty? (lib/order-bys query 1))) (is (empty? (lib/fields query 1))) - ;; Stage 2 (is (=? [[:< {} [:field {} "half-discount"] 500]] (lib/filters query 2))) - (is (empty? (lib/fields query 2))) - (is (= 25 (lib/current-limit query 2)))))) (deftest ^:parallel test-native-query-basic-test @@ -1026,7 +984,6 @@ :display-name "Venue Name"}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues WHERE name = {{venue_name}}")) - (is (=? {"venue_name" {:type :text :name "venue_name" :display-name "Venue Name"}} @@ -1075,7 +1032,6 @@ :display-name "Is Active"}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM users WHERE active = {{is_active}}")) - (is (=? {"is_active" {:type :boolean :name "is_active" :display-name "Is Active"}} @@ -1094,7 +1050,6 @@ :widget-type :text}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues WHERE {{category_filter}}")) - (is (=? {"category_filter" {:type :dimension :name "category_filter" :display-name "Category Filter" @@ -1114,7 +1069,6 @@ :dimension (meta/id :orders :created-at)}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM orders WHERE {{date_unit}}")) - (is (=? {"date_unit" {:type :temporal-unit :name "date_unit" :display-name "Date Unit" @@ -1133,7 +1087,6 @@ :snippet-name "my-snippet"}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM {{snippet: my-snippet}}")) - (is (=? {"snippet: my-snippet" {:type :snippet :name "snippet: my-snippet" :display-name "My Snippet" @@ -1152,7 +1105,6 @@ :card-id 1}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM {{#123}}")) - (is (=? {"#123" {:type :card :name "#123" :display-name "Card 123" @@ -1178,7 +1130,6 @@ :widget-type :text}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues WHERE name = {{name}} AND price > {{min_price}} AND {{category_filter}}")) - (is (=? {"name" {:type :text :name "name" :display-name "Name"} @@ -1205,7 +1156,6 @@ :required true}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues WHERE name = {{venue_name}}")) - (is (=? {"venue_name" {:type :text :name "venue_name" :display-name "Custom Name" @@ -1239,7 +1189,6 @@ :required true}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues WHERE name = {{venue_name}}")) - (is (=? {"venue_name" {:type :text :name "venue_name" :display-name "Custom Name" @@ -1262,7 +1211,6 @@ :required true}}})] (is (=? (lib/raw-native-query query) "SELECT * FROM venues [WHERE name = {{venue_name}}]")) - (is (=? {"venue_name" {:type :text :name "venue_name" :display-name "Custom Name" diff --git a/test/metabase/lib/query_test.cljc b/test/metabase/lib/query_test.cljc index a94189485089..c2e099a7ba6b 100644 --- a/test/metabase/lib/query_test.cljc +++ b/test/metabase/lib/query_test.cljc @@ -101,7 +101,6 @@ ;; tech debt issue: #39376 #_{:base-type :type/Integer} "CC"]]]}]}]} - (lib/query meta/metadata-provider converted-query)))))) (deftest ^:parallel stage-count-test diff --git a/test/metabase/lib/remove_replace_test.cljc b/test/metabase/lib/remove_replace_test.cljc index 8dc139fd147f..cb5820729a80 100644 --- a/test/metabase/lib/remove_replace_test.cljc +++ b/test/metabase/lib/remove_replace_test.cljc @@ -69,7 +69,6 @@ (-> query (lib/remove-clause (first conditions)) (lib/remove-clause (second conditions))))))) - (testing "a cascading delete that removes the final join condition should remove the whole join (#36690)" (let [base (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/join (lib/join-clause (meta/table-metadata :products) @@ -1224,7 +1223,6 @@ agg (first (lib/aggregations query)) brk (first (lib/breakouts query))] (is (= :all (-> base :stages first :joins first :fields))) - (testing "no change to join" (testing "when removing just the aggregation" (is (= (m/dissoc-in query [:stages 0 :aggregation]) diff --git a/test/metabase/lib/schema/expression/arithmetic_test.cljc b/test/metabase/lib/schema/expression/arithmetic_test.cljc index 7453f3d26e6e..516f580a15d3 100644 --- a/test/metabase/lib/schema/expression/arithmetic_test.cljc +++ b/test/metabase/lib/schema/expression/arithmetic_test.cljc @@ -105,7 +105,6 @@ [:interval {:lib/uuid "00000000-0000-0000-0000-000000000002"} 1 :year] [:field {:lib/uuid "00000000-0000-0000-0000-000000000001"} 1] :type/Temporal) - (testing "special case: subtracting two :type/Dates yields :type/Interval (#37263)" (is (= :type/Interval (expression/type-of diff --git a/test/metabase/lib/schema/literal_test.cljc b/test/metabase/lib/schema/literal_test.cljc index 8cdb6f3eea4f..68ab5e28e7e8 100644 --- a/test/metabase/lib/schema/literal_test.cljc +++ b/test/metabase/lib/schema/literal_test.cljc @@ -95,7 +95,6 @@ #?@(:clj ([:value {:lib/uuid "00000000-0000-0000-0000-000000000000", :effective-type :type/Number} (Object.)] :mbql.clause/value - [:value {:lib/uuid "00000000-0000-0000-0000-000000000000", :effective-type :type/Number} (Object.)] ::expression/number)))) diff --git a/test/metabase/lib/schema/metadata_test.cljc b/test/metabase/lib/schema/metadata_test.cljc index 53dacd410803..60df6620a1c7 100644 --- a/test/metabase/lib/schema/metadata_test.cljc +++ b/test/metabase/lib/schema/metadata_test.cljc @@ -157,12 +157,10 @@ :metabase.lib.field/original-effective-type :type/Text :metabase.lib.field/simple-display-name "Category: Name" :metabase.lib.query/transformation-added-base-type true) - (testing "new key already present takes precedence" (is (= (assoc base :lib/temporal-unit :year) (lib/normalize ::lib.schema.metadata/column {:name "X", :metabase.lib.field/temporal-unit :month, :lib/temporal-unit :year})))) - (testing "old keys are disallowed by the schema" (are [old-key value] (not (mr/validate ::lib.schema.metadata/column (assoc base old-key value))) diff --git a/test/metabase/lib/schema/order_by_test.cljc b/test/metabase/lib/schema/order_by_test.cljc index 34ffd60356e5..cace03137c36 100644 --- a/test/metabase/lib/schema/order_by_test.cljc +++ b/test/metabase/lib/schema/order_by_test.cljc @@ -62,7 +62,6 @@ (testing "invalid order-bys do not conform to schema" (binding [lib.schema.expression/*suppress-expression-type-check?* nil] (is (mr/explain ::lib.schema.order-by/order-bys invalid-order-bys)))) - (testing "non-distinct order-bys do not conform to schema" (is (mr/explain ::lib.schema.order-by/order-bys (conj valid-order-bys (first valid-order-bys)))))) diff --git a/test/metabase/lib/schema/ref_test.cljc b/test/metabase/lib/schema/ref_test.cljc index ea2cfb1b3bdf..19dbd6170b10 100644 --- a/test/metabase/lib/schema/ref_test.cljc +++ b/test/metabase/lib/schema/ref_test.cljc @@ -80,13 +80,11 @@ (lib/normalize [:field {old-key value, :lib/uuid id} 123])) :metabase.lib.field/original-effective-type :type/Text :metabase.lib.query/transformation-added-base-type :type/Integer) - (testing "new key already present takes precedence" (is (= [:field (assoc base-opts :lib/original-effective-type :type/Integer) 123] (lib/normalize [:field {:lib/uuid id :metabase.lib.field/original-effective-type :type/Text :lib/original-effective-type :type/Integer} 123])))) - (testing "old keys are disallowed by the schema" (are [old-key value] (not (mr/validate :mbql.clause/field [:field (assoc base-opts old-key value) 123])) diff --git a/test/metabase/lib/swap_test.cljc b/test/metabase/lib/swap_test.cljc index e35e8a7fe6bb..e224d0db9e37 100644 --- a/test/metabase/lib/swap_test.cljc +++ b/test/metabase/lib/swap_test.cljc @@ -26,7 +26,7 @@ (= target-clause clause) source-clause :else clause)) (clause-fn swapped)) - ;; And didn't change anything else. + ;; And didn't change anything else. (= (m/dissoc-in query [:stages 0 clause-key]) (m/dissoc-in swapped [:stages 0 clause-key])))))))) @@ -46,7 +46,7 @@ (as-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) $q (lib/breakout $q (meta/field-metadata :products :category)) (lib/breakout $q (meta/field-metadata :people :source)) - ;; Deliberately including the same field three times: without binning, and with two different binning settings. + ;; Deliberately including the same field three times: without binning, and with two different binning settings. (lib/breakout $q (meta/field-metadata :orders :subtotal)) (lib/breakout $q (lib/with-binning (meta/field-metadata :orders :subtotal) @@ -55,7 +55,7 @@ (meta/field-metadata :orders :subtotal) (nth (lib/available-binning-strategies $q (meta/field-metadata :orders :subtotal)) 2))) - ;; Likewise including multiple temporal buckets. + ;; Likewise including multiple temporal buckets. (lib/breakout $q (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :month)) (lib/breakout $q (lib/with-temporal-bucket (meta/field-metadata :orders :created-at) :year))))) diff --git a/test/metabase/lib/test_util/generators.cljc b/test/metabase/lib/test_util/generators.cljc index 0f2abbdc93c5..09207b739f04 100644 --- a/test/metabase/lib/test_util/generators.cljc +++ b/test/metabase/lib/test_util/generators.cljc @@ -411,7 +411,6 @@ (testing "increments the stage-count" (is (= (inc (lib/stage-count before)) (lib/stage-count after)))) - (testing "adds a new, empty stage" (is (empty? (all-stage-parts after -1)))))) @@ -452,7 +451,6 @@ "\n\nwith after query\n" (u/pprint-to-str after)) (before-and-after before after step)) ctx' - (catch #?(:clj Throwable :cljs js/Error) e (throw (ex-info "Error in before/after testing" (-> ctx (dissoc :query) diff --git a/test/metabase/lib/test_util/huge_query_metadata_providers.cljc b/test/metabase/lib/test_util/huge_query_metadata_providers.cljc index af07009d4237..0c7ad09b5f37 100644 --- a/test/metabase/lib/test_util/huge_query_metadata_providers.cljc +++ b/test/metabase/lib/test_util/huge_query_metadata_providers.cljc @@ -743,7 +743,7 @@ :table-id table-id :fk-target-field-id fk-target-field-id :has-field-values has-field-values - ;; And now the fixed parts + ;; And now the fixed parts :lib/type :metadata/column :description nil :database-partitioned nil diff --git a/test/metabase/lib/underlying_test.cljc b/test/metabase/lib/underlying_test.cljc index cdd72eca3baa..9b7ab1b042e4 100644 --- a/test/metabase/lib/underlying_test.cljc +++ b/test/metabase/lib/underlying_test.cljc @@ -25,7 +25,6 @@ 100)))] (is (identical? query (lib.underlying/top-level-query query)))))) - (testing "returns the last stage with an aggregation" (let [agg-0 (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/aggregate (lib/count)) @@ -42,7 +41,6 @@ (lib.underlying/top-level-query agg-1))) (is (= (update agg-0 :stages pop) (lib.underlying/top-level-query agg-0))))) - (testing "returns the last stage with a breakout" (let [brk-0 (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/breakout (meta/field-metadata :orders :product-id)) @@ -119,7 +117,6 @@ (lib/returned-columns query)) _ (is (some? binned-col)) query (lib/append-stage query)] - (doseq [[col key-name] [[temporal-col "temporal-unit"] [binned-col "binning"]] rename? [true false]] diff --git a/test/metabase/lib_be/hash_test.clj b/test/metabase/lib_be/hash_test.clj index 470a2ad62ae2..3d07c1819d50 100644 --- a/test/metabase/lib_be/hash_test.clj +++ b/test/metabase/lib_be/hash_test.clj @@ -241,7 +241,7 @@ (lib/aggregate (lib/count)) (lib/aggregate (lib/sum (meta/field-metadata :venues :price))) (as-> $q - ;; Add an aggregation expression that references the first two aggregations + ;; Add an aggregation expression that references the first two aggregations (lib/aggregate $q (lib/+ (lib/aggregation-ref $q 0) (lib/aggregation-ref $q 1)))))) diff --git a/test/metabase/lib_metric/ast/build_test.cljc b/test/metabase/lib_metric/ast/build_test.cljc index 94fa9a9140a8..1efaf32721fe 100644 --- a/test/metabase/lib_metric/ast/build_test.cljc +++ b/test/metabase/lib_metric/ast/build_test.cljc @@ -87,7 +87,6 @@ (let [node (ast.build/table-node 1)] (is (= {:node/type :ast/table :id 1} node)) (is (nil? (me/humanize (mr/explain ::ast.schema/table-node node)))))) - (testing "creates valid table node with name" (let [node (ast.build/table-node 1 "orders")] (is (= {:node/type :ast/table :id 1 :name "orders"} node)) @@ -98,7 +97,6 @@ (let [node (ast.build/column-node 1)] (is (= {:node/type :ast/column :id 1} node)) (is (nil? (me/humanize (mr/explain ::ast.schema/column-node node)))))) - (testing "creates valid column node with all fields" (let [node (ast.build/column-node 1 "category" 10)] (is (= {:node/type :ast/column :id 1 :name "category" :table-id 10} node)) @@ -131,36 +129,29 @@ (deftest ^:parallel from-definition-test (let [ast (ast.build/from-definition (sample-definition))] - (testing "creates valid AST structure" (is (nil? (me/humanize (mr/explain ::ast.schema/ast ast))))) - (testing "has correct root structure" (is (= :ast/root (:node/type ast))) (is (some? (:expression ast))) (is (some? (:metadata-provider ast)))) - (testing "expression is a single leaf" (is (= :expression/leaf (get-in ast [:expression :node/type]))) (is (string? (get-in ast [:expression :uuid])))) - (let [source-query (get-in ast [:expression :ast])] (testing "leaf wraps a source-query" (is (= :ast/source-query (:node/type source-query))) (is (nil? (:filter source-query))) (is (= [] (:group-by source-query)))) - (testing "has correct source" (let [source (:source source-query)] (is (= :source/metric (:node/type source))) (is (= 42 (:id source))) (is (= "Total Revenue" (:name source))))) - (testing "has dimensions as nodes" (is (= 2 (count (:dimensions source-query)))) (is (every? #(= :ast/dimension (:node/type %)) (:dimensions source-query))) (is (= uuid-1 (get-in source-query [:dimensions 0 :id])))) - (testing "has mappings as nodes" (is (= 2 (count (:mappings source-query)))) (is (every? #(= :ast/dimension-mapping (:node/type %)) (:mappings source-query))))))) @@ -172,13 +163,10 @@ :projections [] :metadata-provider (make-test-provider (sample-metric-metadata) (sample-measure-metadata))} ast (ast.build/from-definition measure-def)] - (testing "creates valid AST for measure" (is (nil? (me/humanize (mr/explain ::ast.schema/ast ast))))) - (testing "has measure source type" (is (= :source/measure (get-in ast [:expression :ast :source :node/type])))) - (testing "extracts sum aggregation" (is (= :aggregation/sum (get-in ast [:expression :ast :source :aggregation :node/type])))))) @@ -191,21 +179,18 @@ :dimension-id uuid-1} result)) (is (nil? (me/humanize (mr/explain ::ast.schema/dimension-ref-node result)))))) - (testing "converts dimension reference with temporal-unit option" (let [result (ast.build/dimension-ref->ast-dimension-ref [:dimension {"temporal-unit" "year"} uuid-1])] (is (= {:node/type :ast/dimension-ref :dimension-id uuid-1 :options {:temporal-unit :year}} result)))) - (testing "converts dimension reference with binning option" (let [result (ast.build/dimension-ref->ast-dimension-ref [:dimension {"binning" {"strategy" "default"}} uuid-1])] (is (= {:node/type :ast/dimension-ref :dimension-id uuid-1 :options {:binning {:strategy :default}}} result)))) - (testing "converts binning strategy string values to keywords" (let [result (ast.build/dimension-ref->ast-dimension-ref [:dimension {"binning" {"strategy" "num-bins" "num-bins" 10}} uuid-1])] @@ -264,7 +249,6 @@ (is (= :filter/in (:node/type result))) (is (= :in (:operator result))) (is (= [1 2 3] (:values result))))) - (testing "converts not-in filters" (let [result (ast.build/mbql-filter->ast-filter [:not-in {} [:dimension {} uuid-1] "a" "b"])] (is (= :filter/in (:node/type result))) @@ -279,7 +263,6 @@ (is (= -30 (:value result))) (is (= :day (:unit result))) (is (nil? (:offset-value result))))) - (testing "converts relative-time-interval filter with positional offsets" (let [result (ast.build/mbql-filter->ast-filter [:relative-time-interval {} [:dimension {} uuid-1] -7 "day" -1 "month"])] @@ -289,7 +272,6 @@ (is (= :day (:unit result))) (is (= -1 (:offset-value result))) (is (= :month (:offset-unit result))))) - (testing "converts time-interval filter with offsets in opts map" (let [result (ast.build/mbql-filter->ast-filter [:time-interval {:offset-value -1 :offset-unit :week} [:dimension {} uuid-1] -30 "day"])] @@ -308,14 +290,12 @@ (is (= :filter/and (:node/type result))) (is (= 2 (count (:children result)))) (is (every? #(= :filter/comparison (:node/type %)) (:children result))))) - (testing "converts or filter" (let [result (ast.build/mbql-filter->ast-filter [:or {} [:= {} [:dimension {} uuid-1] 1] [:= {} [:dimension {} uuid-2] 2]])] (is (= :filter/or (:node/type result))) (is (= 2 (count (:children result)))))) - (testing "converts not filter" (let [result (ast.build/mbql-filter->ast-filter [:not {} [:= {} [:dimension {} uuid-1] 1]])] (is (= :filter/not (:node/type result))) @@ -377,13 +357,11 @@ (testing "single filter returns that filter" (let [result (ast.build/mbql-filters->ast-filter [[:= {} [:dimension {} uuid-1] 42]])] (is (= :filter/comparison (:node/type result))))) - (testing "multiple filters combined with and" (let [result (ast.build/mbql-filters->ast-filter [[:= {} [:dimension {} uuid-1] 1] [:= {} [:dimension {} uuid-2] 2]])] (is (= :filter/and (:node/type result))) (is (= 2 (count (:children result)))))) - (testing "empty filters returns nil" (is (nil? (ast.build/mbql-filters->ast-filter []))))) @@ -395,10 +373,8 @@ :filter [:= {} [:dimension {} uuid-1] 42]}]) ast (ast.build/from-definition definition-with-filters) source-query (get-in ast [:expression :ast])] - (testing "creates valid AST structure with filter" (is (nil? (me/humanize (mr/explain ::ast.schema/ast ast))))) - (testing "has filter node" (is (some? (:filter source-query))) (is (= :filter/comparison (get-in source-query [:filter :node/type]))) @@ -413,10 +389,8 @@ [:dimension {"temporal-unit" "year"} uuid-2]]}]) ast (ast.build/from-definition definition-with-projections) source-query (get-in ast [:expression :ast])] - (testing "creates valid AST structure with group-by" (is (nil? (me/humanize (mr/explain ::ast.schema/ast ast))))) - (testing "has group-by dimension refs" (is (= 2 (count (:group-by source-query)))) (is (every? #(= :ast/dimension-ref (:node/type %)) (:group-by source-query))) @@ -480,19 +454,15 @@ (deftest ^:parallel from-definition-arithmetic-test (let [ast (ast.build/from-definition (arithmetic-definition))] - (testing "arithmetic expression builds AST with :expression key" (is (= :ast/root (:node/type ast))) (is (some? (:expression ast))) (is (nil? (:source ast)))) - (testing "expression root is arithmetic node" (is (= :expression/arithmetic (get-in ast [:expression :node/type]))) (is (= :+ (get-in ast [:expression :operator])))) - (testing "arithmetic has two children" (is (= 2 (count (get-in ast [:expression :children]))))) - (testing "each child is an expression leaf with complete sub-AST" (doseq [child (get-in ast [:expression :children])] (is (= :expression/leaf (:node/type child))) @@ -502,7 +472,6 @@ (is (some? (:source sub-ast))) (is (seq (:dimensions sub-ast))) (is (seq (:mappings sub-ast)))))) - (testing "leaf UUIDs match expression" (let [uuids (set (map :uuid (get-in ast [:expression :children])))] (is (contains? uuids arith-uuid-a)) @@ -513,7 +482,6 @@ :filters [{:lib/uuid arith-uuid-a :filter [:= {} [:dimension {} uuid-1] 42]}]) ast (ast.build/from-definition definition)] - (testing "instance filters are partitioned to matching leaf sub-ASTs" (let [children (get-in ast [:expression :children]) child-a (first (filter #(= arith-uuid-a (:uuid %)) children)) @@ -528,14 +496,11 @@ :projections [] :metadata-provider (make-test-provider (sample-metric-metadata) (sample-measure-metadata))} ast (ast.build/from-definition definition)] - (testing "creates valid AST with constant child" (is (nil? (me/humanize (mr/explain ::ast.schema/ast ast))))) - (testing "expression root is arithmetic" (is (= :expression/arithmetic (get-in ast [:expression :node/type]))) (is (= :* (get-in ast [:expression :operator])))) - (testing "has one leaf and one constant child" (let [children (get-in ast [:expression :children])] (is (= 2 (count children))) @@ -554,7 +519,6 @@ :lib/uuid arith-uuid-b :projection [[:dimension {} uuid-1]]}]) ast (ast.build/from-definition definition)] - (testing "projections are assigned to matching leaf sub-ASTs" (doseq [child (get-in ast [:expression :children])] (is (= 1 (count (get-in child [:ast :group-by])))))))) diff --git a/test/metabase/lib_metric/ast/compile_test.cljc b/test/metabase/lib_metric/ast/compile_test.cljc index 0481a7cfccfa..a75d19ddef5b 100644 --- a/test/metabase/lib_metric/ast/compile_test.cljc +++ b/test/metabase/lib_metric/ast/compile_test.cljc @@ -42,26 +42,21 @@ (deftest ^:parallel compile-to-mbql-basic-test (let [result (ast.compile/compile-to-mbql sample-ast)] - (testing "produces valid MBQL structure" (is (= :mbql/query (:lib/type result))) (is (= 1 (:database result))) (is (= 1 (count (:stages result))))) - (testing "has correct stage structure" (let [stage (first (:stages result))] (is (= :mbql.stage/mbql (:lib/type stage))) (is (= 100 (:source-table stage))) (is (= 1 (count (:aggregation stage)))))) - (testing "compiles count aggregation" (let [agg (first (get-in result [:stages 0 :aggregation]))] (is (= :count (first agg))) (is (string? (get-in agg [1 :lib/uuid]))))) - (testing "has no filters when AST has none" (is (nil? (get-in result [:stages 0 :filters])))) - (testing "has no breakout when AST has none" (is (nil? (get-in result [:stages 0 :breakout])))))) @@ -327,7 +322,6 @@ ;; Values (is (= 1 (nth filter-clause 3))) (is (= 7 (nth filter-clause 4))))) - (testing "compiles filter with dimension-expression node without extra args" (let [dim-expr {:node/type :ast/dimension-expression :expression-op :get-month @@ -385,7 +379,6 @@ field-ref (first breakout)] (is (= :field (first field-ref))) (is (= {:strategy :default} (get-in field-ref [1 :binning]))))) - (testing "compiles group-by with binning strategy :num-bins" (let [dim-ref-with-binning {:node/type :ast/dimension-ref :dimension-id uuid-1 @@ -396,7 +389,6 @@ field-ref (first breakout)] (is (= :field (first field-ref))) (is (= {:strategy :num-bins :num-bins 10} (get-in field-ref [1 :binning]))))) - (testing "compiles group-by with binning strategy :bin-width" (let [dim-ref-with-binning {:node/type :ast/dimension-ref :dimension-id uuid-1 @@ -475,19 +467,15 @@ (assoc :group-by [dim-ref-1 dim-ref-2])) result (ast.compile/compile-to-mbql full-ast :limit 1000) stage (first (:stages result))] - (testing "has filters" (is (seq (:filters stage))) ;; Should be wrapped in :and since we have compound filter (let [filter (first (:filters stage))] (is (= :and (first filter))))) - (testing "has breakouts" (is (= 2 (count (:breakout stage))))) - (testing "has limit" (is (= 1000 (:limit stage)))) - (testing "has aggregation" (is (= 1 (count (:aggregation stage))))))) @@ -517,7 +505,6 @@ ast-with-source-filter (assoc-in sample-ast [:source :filters] source-filter) result (ast.compile/compile-to-mbql ast-with-source-filter) filters (get-in result [:stages 0 :filters])] - (testing "includes source filters in output" (is (= 1 (count filters))) (is (= := (first (first filters))))))) @@ -537,7 +524,6 @@ result (ast.compile/compile-to-mbql ast) filters (get-in result [:stages 0 :filters]) filter (first filters)] - (testing "combines source and user filters with :and" (is (= 1 (count filters))) (is (= :and (first filter))) @@ -561,18 +547,15 @@ (deftest ^:parallel compile-two-stage-query-test (let [result (ast.compile/compile-to-mbql ast-with-joins)] - (testing "produces two stages when joins present" (is (= :mbql/query (:lib/type result))) (is (= 2 (count (:stages result))))) - (testing "stage 0 has source-table and joins" (let [stage-0 (first (:stages result))] (is (= :mbql.stage/mbql (:lib/type stage-0))) (is (= 100 (:source-table stage-0))) (is (= 1 (count (:joins stage-0)))) (is (= sample-join (first (:joins stage-0)))))) - (testing "stage 1 has aggregation" (let [stage-1 (second (:stages result))] (is (= :mbql.stage/mbql (:lib/type stage-1))) @@ -597,7 +580,6 @@ result (ast.compile/compile-to-mbql ast) stage-0-join (first (:joins (first (:stages result))))] (is (= :all (:fields stage-0-join))))) - (testing "join with explicit :fields is overridden to :all (dimension system advertises all joined columns)" (let [join-with-fields (assoc sample-join :fields [[:field {:join-alias "Products"} 20]]) ast (assoc-in sample-ast [:source :joins] @@ -620,12 +602,10 @@ result (ast.compile/compile-to-mbql ast) stage-0 (first (:stages result)) stage-1 (second (:stages result))] - (testing "source filters go in stage 0" (is (= 1 (count (:filters stage-0)))) (let [filter (first (:filters stage-0))] (is (= := (first filter))))) - (testing "user filters go in stage 1" (is (= 1 (count (:filters stage-1)))) (let [filter (first (:filters stage-1))] @@ -644,7 +624,6 @@ result (ast.compile/compile-to-mbql ast-with-groupby) stage-0 (first (:stages result)) stage-1 (second (:stages result))] - (testing "breakouts appear only in stage 1" (is (nil? (:breakout stage-0))) (is (= 2 (count (:breakout stage-1))))))) @@ -653,7 +632,6 @@ (let [result (ast.compile/compile-to-mbql ast-with-joins :limit 100) stage-0 (first (:stages result)) stage-1 (second (:stages result))] - (testing "limit appears only in stage 1" (is (nil? (:limit stage-0))) (is (= 100 (:limit stage-1)))))) @@ -672,7 +650,6 @@ (is (= :mbql/query (:lib/type result))) (is (= 1 (:database result))) (is (= 1 (count (:stages result))))) - (testing "stage has no :aggregation key" (let [stage (first (:stages result))] (is (= :mbql.stage/mbql (:lib/type stage))) diff --git a/test/metabase/lib_metric/ast/walk_test.cljc b/test/metabase/lib_metric/ast/walk_test.cljc index 33eb417b3d57..adf7e2b53e9f 100644 --- a/test/metabase/lib_metric/ast/walk_test.cljc +++ b/test/metabase/lib_metric/ast/walk_test.cljc @@ -87,7 +87,6 @@ (is (ast.walk/node? {:node/type :ast/root})) (is (ast.walk/node? {:node/type :filter/comparison :operator := :dimension dim-ref-1 :values [1]})) (is (ast.walk/node? {:node/type :ast/column :id 1}))) - (testing "returns false for non-nodes" (is (not (ast.walk/node? {}))) (is (not (ast.walk/node? nil))) @@ -100,7 +99,6 @@ (is (ast.walk/filter-node? filter-b)) (is (ast.walk/filter-node? (and-filter filter-a filter-b))) (is (ast.walk/filter-node? (not-filter filter-a)))) - (testing "returns false for non-filter nodes" (is (not (ast.walk/filter-node? {:node/type :ast/root}))) (is (not (ast.walk/filter-node? {:node/type :ast/column :id 1}))) @@ -110,7 +108,6 @@ (testing "returns true for source nodes" (is (ast.walk/source-node? {:node/type :source/metric :id 1})) (is (ast.walk/source-node? {:node/type :source/measure :id 1}))) - (testing "returns false for non-source nodes" (is (not (ast.walk/source-node? {:node/type :ast/root}))) (is (not (ast.walk/source-node? filter-a))))) @@ -119,7 +116,6 @@ (testing "returns true for dimension ref nodes" (is (ast.walk/dimension-ref-node? dim-ref-1)) (is (ast.walk/dimension-ref-node? (dimension-ref uuid-1 {:temporal-unit :month})))) - (testing "returns false for other nodes" (is (not (ast.walk/dimension-ref-node? {:node/type :ast/dimension :id uuid-1}))) (is (not (ast.walk/dimension-ref-node? filter-a))))) @@ -204,7 +200,6 @@ #(= :filter/comparison (:node/type %)) sample-ast)] (is (= :filter/comparison (:node/type found))))) - (testing "returns nil when not found" (let [found (ast.walk/find-first #(= :nonexistent (:node/type %)) @@ -245,13 +240,11 @@ ;; Since only one child remains, :filter/and should be unwrapped (is (= :filter/comparison (get-in result [:expression :ast :filter :node/type]))) (is (= := (get-in result [:expression :ast :filter :operator]))))) - (testing "removes all filters leaving nil" (let [all-comparison (ast.walk/remove-filters #(= :filter/comparison (:node/type %)) sample-ast)] (is (nil? (get-in all-comparison [:expression :ast :filter]))))) - (testing "handles nested compound filters" (let [nested-source-query (assoc sample-source-query :filter (and-filter @@ -265,7 +258,6 @@ ;; Inner :or should unwrap to just filter-a (is (= :filter/and (get-in result [:expression :ast :filter :node/type]))) (is (= 2 (count (get-in result [:expression :ast :filter :children])))))) - (testing "handles nested compound where all inner children are removed" (let [nested-source-query (assoc sample-source-query :filter (and-filter diff --git a/test/metabase/lib_metric/dimension/jvm_test.clj b/test/metabase/lib_metric/dimension/jvm_test.clj index 652616ca05ad..af4dbeeb6b11 100644 --- a/test/metabase/lib_metric/dimension/jvm_test.clj +++ b/test/metabase/lib_metric/dimension/jvm_test.clj @@ -27,7 +27,6 @@ (str "column " (:name col) " should have :has-field-values")) (is (#{:list :search :none} (:has-field-values col)) (str "column " (:name col) " should have a valid :has-field-values value")))))) - (testing "returns empty collection unchanged" (is (= [] (dimension.jvm/enrich-columns-with-has-field-values []))))) diff --git a/test/metabase/lib_metric/dimension_test.cljc b/test/metabase/lib_metric/dimension_test.cljc index 15192a956ae8..efb3e6768294 100644 --- a/test/metabase/lib_metric/dimension_test.cljc +++ b/test/metabase/lib_metric/dimension_test.cljc @@ -443,13 +443,11 @@ (is (= :status/active (:status normalized))) (is (= :search (:has-field-values normalized))) (is (= :field (get-in normalized [:sources 0 :type]))))) - (testing "leaves already-keywordized values unchanged" (let [dim {:id "dim-2" :name "col" :status :status/active :has-field-values :list} normalized (lib-metric.dimension/normalize-persisted-dimension dim)] (is (= :status/active (:status normalized))) (is (= :list (:has-field-values normalized))))) - (testing "no-op when optional fields are absent" (let [dim {:id "dim-3" :name "col"}] (is (= dim (lib-metric.dimension/normalize-persisted-dimension dim)))))) diff --git a/test/metabase/llm/anthropic_test.clj b/test/metabase/llm/anthropic_test.clj index 00c9ba24fafc..463e39ef274f 100644 --- a/test/metabase/llm/anthropic_test.clj +++ b/test/metabase/llm/anthropic_test.clj @@ -18,7 +18,6 @@ :input {:sql "SELECT 1"}}]}] (is (= {:sql "SELECT 1"} (#'anthropic/extract-tool-input response))))) - (testing "multiple content blocks finds tool_use" (let [response {:content [{:type "text" :text "thinking..."} {:type "tool_use" @@ -27,19 +26,15 @@ :input {:sql "SELECT 1" :explanation "Simple query"}}]}] (is (= {:sql "SELECT 1" :explanation "Simple query"} (#'anthropic/extract-tool-input response))))) - (testing "no tool_use block returns nil" (let [response {:content [{:type "text" :text "no tool"}]}] (is (nil? (#'anthropic/extract-tool-input response))))) - (testing "empty content returns nil" (is (nil? (#'anthropic/extract-tool-input {:content []}))) (is (nil? (#'anthropic/extract-tool-input {})))) - (testing "tool_use without input returns nil" (let [response {:content [{:type "tool_use" :id "123" :name "generate_sql"}]}] (is (nil? (#'anthropic/extract-tool-input response))))) - (testing "returns first tool_use when multiple present" (let [response {:content [{:type "tool_use" :id "1" @@ -73,19 +68,16 @@ (is (= [{:role "user" :content "test"}] (:messages body))) (is (vector? (:tools body))) (is (= {:type "tool" :name "generate_sql"} (:tool_choice body)))))) - (testing "uses configured max_tokens setting" (mt/with-temporary-setting-values [llm-max-tokens 8192] (let [body (#'anthropic/build-request-body {:model "claude-sonnet-4-5-20250929" :messages [{:role "user" :content "test"}]})] (is (= 8192 (:max_tokens body)))))) - (testing "includes system prompt when provided" (let [body (#'anthropic/build-request-body {:model "claude-sonnet-4-5-20250929" :system "You are a SQL expert" :messages [{:role "user" :content "test"}]})] (is (= "You are a SQL expert" (:system body))))) - (testing "omits system when not provided" (let [body (#'anthropic/build-request-body {:model "claude-sonnet-4-5-20250929" :messages [{:role "user" :content "test"}]})] diff --git a/test/metabase/llm/api_test.clj b/test/metabase/llm/api_test.clj index fa206b90a6e6..3d79aa23db92 100644 --- a/test/metabase/llm/api_test.clj +++ b/test/metabase/llm/api_test.clj @@ -24,10 +24,8 @@ (is (= :postgres (#'api/database-engine (:id postgres-db)))) (is (= :mysql (#'api/database-engine (:id mysql-db)))) (is (= :bigquery (#'api/database-engine (:id bigquery-db))))) - (testing "nil database returns nil" (is (nil? (#'api/database-engine nil)))) - (testing "non-existent database returns nil" (is (nil? (#'api/database-engine 99999999)))))) @@ -38,7 +36,6 @@ (let [instructions (#'api/load-dialect-instructions :postgres)] (is (string? instructions)) (is (str/includes? instructions "PostgreSQL")))) - (testing "nil engine returns nil" (is (nil? (#'api/load-dialect-instructions nil))))) @@ -55,14 +52,12 @@ (filter #(= "table" (:model %))) (map :id) set))))) - (testing "returns empty set for empty input" (is (= #{} (->> [] (filter #(= "table" (:model %))) (map :id) set)))) - (testing "returns empty set when no tables present" (let [entities [{:model "card" :id 1} {:model "question" :id 2}]] @@ -81,7 +76,6 @@ (set/union (or frontend #{}) (or explicit #{}) (or implicit #{})))))) - (testing "nil sources treated as empty sets" (is (= #{1 2} (set/union (or nil #{}) @@ -95,7 +89,6 @@ (let [prompt "Join [Orders](metabase://table/123) with [Users](metabase://table/456)"] (is (= #{123 456} (llm.context/parse-table-mentions prompt))))) - (testing "multiple mentions of same table deduplicated" (let [prompt "[Orders](metabase://table/123) and again [Orders](metabase://table/123)"] (is (= #{123} @@ -110,13 +103,11 @@ (is (string? prompt)) (is (str/includes? prompt "PostgreSQL")) (is (str/includes? prompt "CREATE TABLE users")))) - (testing "includes dialect instructions when provided" (let [prompt (#'api/build-system-prompt {:dialect "PostgreSQL" :schema-ddl "CREATE TABLE users (id INTEGER);" :dialect-instructions "Use LIMIT instead of TOP"})] (is (str/includes? prompt "Use LIMIT instead of TOP")))) - (testing "includes source SQL when provided" (let [prompt (#'api/build-system-prompt {:dialect "PostgreSQL" :schema-ddl "CREATE TABLE users (id INTEGER);" @@ -133,7 +124,6 @@ {:prompt "test" :database_id (:id db)})] (is (str/includes? (str response) "not configured"))))) - (testing "400 when no tables found" (with-redefs [llm.settings/llm-anthropic-api-key (constantly "sk-ant-test")] (let [response (mt/user-http-request :rasta :post 400 "llm/generate-sql" diff --git a/test/metabase/llm/context_test.clj b/test/metabase/llm/context_test.clj index 6581fe3c1630..754216c840d4 100644 --- a/test/metabase/llm/context_test.clj +++ b/test/metabase/llm/context_test.clj @@ -13,36 +13,28 @@ (testing "extracts single table ID" (is (= #{42} (context/parse-table-mentions "Show [Orders](metabase://table/42)")))) - (testing "extracts multiple table IDs" (is (= #{42 15} (context/parse-table-mentions "Join [Orders](metabase://table/42) with [Products](metabase://table/15)")))) - (testing "handles no mentions" (is (= #{} (context/parse-table-mentions "Show me all sales")))) - (testing "handles nil input" (is (nil? (context/parse-table-mentions nil)))) - (testing "handles empty string" (is (= #{} (context/parse-table-mentions "")))) - (testing "ignores malformed mentions - invalid ID" (is (= #{42} (context/parse-table-mentions "[Valid](metabase://table/42) [Invalid](metabase://table/abc)")))) - (testing "handles display names with special characters" (is (= #{123} (context/parse-table-mentions "[Order's & Items](metabase://table/123)")))) - (testing "handles multiple mentions of same table (returns set)" (is (= #{42} (context/parse-table-mentions "[Orders](metabase://table/42) and also [Orders Table](metabase://table/42)")))) - (testing "handles text before and after mentions" (is (= #{1 2} (context/parse-table-mentions @@ -55,12 +47,10 @@ (let [tables [{:name "users" :schema nil :columns [{:name "id" :database_type "INTEGER"}]}] result (#'context/format-schema-ddl tables)] (is (= "CREATE TABLE users (\n id INTEGER\n);" result)))) - (testing "formats table with schema" (let [tables [{:name "users" :schema "public" :columns [{:name "id" :database_type "INTEGER"}]}] result (#'context/format-schema-ddl tables)] (is (= "CREATE TABLE public.users (\n id INTEGER\n);" result)))) - (testing "formats table with multiple columns" (let [tables [{:name "orders" :schema nil @@ -71,20 +61,17 @@ (is (str/includes? result "id INTEGER")) (is (str/includes? result "total DECIMAL")) (is (str/includes? result "created_at TIMESTAMP")))) - (testing "escapes special characters in identifiers" (let [tables [{:name "user data" :schema nil :columns [{:name "first name" :database_type "VARCHAR"}]}] result (#'context/format-schema-ddl tables)] (is (str/includes? result "\"user data\"")) (is (str/includes? result "\"first name\"")))) - (testing "formats multiple tables" (let [tables [{:name "users" :schema nil :columns [{:name "id" :database_type "INTEGER"}]} {:name "orders" :schema nil :columns [{:name "id" :database_type "INTEGER"}]}] result (#'context/format-schema-ddl tables)] (is (str/includes? result "CREATE TABLE users")) (is (str/includes? result "CREATE TABLE orders")))) - (testing "formats table with description" (let [tables [{:name "orders" :schema "public" @@ -92,7 +79,6 @@ :columns [{:name "id" :database_type "INTEGER"}]}] result (#'context/format-schema-ddl tables)] (is (= "-- Customer purchase transactions\nCREATE TABLE public.orders (\n id INTEGER\n);" result)))) - (testing "formats table without description (no comment line)" (let [tables [{:name "orders" :schema nil @@ -116,16 +102,12 @@ (is (str/includes? result "orders")) (is (str/includes? result "id")) (is (str/includes? result "total")))) - (testing "returns nil for empty table-ids" (is (nil? (context/build-schema-context (:id db) #{})))) - (testing "returns nil for nil table-ids" (is (nil? (context/build-schema-context (:id db) nil)))) - (testing "returns nil for nil database-id" (is (nil? (context/build-schema-context nil #{(:id table1)})))) - (testing "returns nil for non-existent tables" (is (nil? (context/build-schema-context (:id db) #{99999}))))))) @@ -152,15 +134,12 @@ (testing "formats integer range" (is (= "range: 1-100, avg: 50" (#'context/format-numeric-stats {:min 1 :max 100 :avg 50})))) - (testing "formats decimal range" (is (= "range: 0.00-999.99, avg: 127.50" (#'context/format-numeric-stats {:min 0.0 :max 999.99 :avg 127.5})))) - (testing "handles missing avg" (is (= "range: 0-100" (#'context/format-numeric-stats {:min 0 :max 100})))) - (testing "returns nil when min/max missing" (is (nil? (#'context/format-numeric-stats {:avg 50}))) (is (nil? (#'context/format-numeric-stats {}))))) @@ -170,12 +149,10 @@ (is (= "2020-01-01 to 2024-12-31" (#'context/format-temporal-stats {:earliest "2020-01-01T00:00:00Z" :latest "2024-12-31T23:59:59Z"})))) - (testing "handles short date strings" (is (= "2020-01-01 to 2024-12-31" (#'context/format-temporal-stats {:earliest "2020-01-01" :latest "2024-12-31"})))) - (testing "returns nil when dates missing" (is (nil? (#'context/format-temporal-stats {:earliest "2020-01-01"}))) (is (nil? (#'context/format-temporal-stats {}))))) @@ -189,54 +166,46 @@ {:description "Customer email address" :semantic_type :type/Email} nil nil)))) - (testing "sample values for category" (is (= "active, inactive, pending" (#'context/build-column-comment {:semantic_type :type/Category} ["active" "inactive" "pending"] nil)))) - (testing "FK reference" (is (= "FK->users.id" (#'context/build-column-comment {:fk_target_field_id 123} nil {:table "users" :field "id"})))) - (testing "semantic type hints" (is (= "PK" (#'context/build-column-comment {:semantic_type :type/PK} nil nil))) (is (= "Email" (#'context/build-column-comment {:semantic_type :type/Email} nil nil)))) - (testing "numeric fingerprint stats" (is (= "range: 0-1000, avg: 127.50" (#'context/build-column-comment {:fingerprint {:type {:type/Number {:min 0 :max 1000 :avg 127.5}}}} nil nil)))) - (testing "temporal fingerprint stats" (is (= "2020-01-01 to 2024-12-31" (#'context/build-column-comment {:fingerprint {:type {:type/DateTime {:earliest "2020-01-01T00:00:00Z" :latest "2024-12-31T23:59:59Z"}}}} nil nil)))) - (testing "combined metadata" (is (= "Order status; pending, shipped, delivered" (#'context/build-column-comment {:description "Order status"} ["pending" "shipped" "delivered"] nil)))) - (testing "returns nil for no metadata" (is (nil? (#'context/build-column-comment {} nil nil))))) (deftest truncate-value-test (testing "short values unchanged" (is (= "active" (#'context/truncate-value "active")))) - (testing "long values truncated" (is (= "this is a very long value t..." (#'context/truncate-value "this is a very long value that exceeds the limit"))))) @@ -257,7 +226,6 @@ (is (str/includes? result "id INTEGER")) (is (str/includes? result "status VARCHAR")) (is (str/includes? result "total DECIMAL")))) - (testing "formats table with mixed commented and non-commented columns" (let [tables [{:name "users" :schema nil @@ -270,7 +238,6 @@ (is (str/includes? result "id INTEGER")) (is (str/includes? result "name VARCHAR")) (is (str/includes? result "email VARCHAR")))) - (testing "formats table without any comments (backward compatible)" (let [tables [{:name "simple" :schema nil @@ -362,7 +329,6 @@ (is (= #{} (context/extract-tables-from-sql nil nil))) (is (= #{} (context/extract-tables-from-sql 1 nil))) (is (= #{} (context/extract-tables-from-sql nil "SELECT 1")))) - (testing "returns empty set for empty SQL" (is (= #{} (context/extract-tables-from-sql 1 ""))) (is (= #{} (context/extract-tables-from-sql 1 " "))))) @@ -373,24 +339,20 @@ (let [result (context/extract-tables-from-sql (mt/id) "SELECT * FROM ORDERS")] (is (set? result)) (is (contains? result (mt/id :orders))))) - (testing "extracts multiple tables from JOIN" (let [result (context/extract-tables-from-sql (mt/id) "SELECT * FROM ORDERS o JOIN PRODUCTS p ON o.PRODUCT_ID = p.ID")] (is (set? result)) (is (contains? result (mt/id :orders))) (is (contains? result (mt/id :products))))) - (testing "extracts tables from subquery" (let [result (context/extract-tables-from-sql (mt/id) "SELECT * FROM (SELECT * FROM PEOPLE) sub")] (is (set? result)) (is (contains? result (mt/id :people))))) - (testing "returns empty set for non-existent table" (let [result (context/extract-tables-from-sql (mt/id) "SELECT * FROM NONEXISTENT_TABLE")] (is (= #{} result)))) - (testing "returns empty set for invalid SQL (graceful failure)" (let [result (context/extract-tables-from-sql (mt/id) "THIS IS NOT SQL")] (is (= #{} result)))))) @@ -403,6 +365,5 @@ "#2" {:type :card :card_id 2} "id" {:type "dimension"} "orders" {:type "table" :table-id 10}})))) - (testing "returns an empty set for missing template tags" (is (= #{} (context/extract-card-ids-from-template-tags nil))))) diff --git a/test/metabase/llm/settings_test.clj b/test/metabase/llm/settings_test.clj index 739da0c36fb6..bf7d23469b88 100644 --- a/test/metabase/llm/settings_test.clj +++ b/test/metabase/llm/settings_test.clj @@ -14,13 +14,11 @@ (mt/discard-setting-changes [llm-anthropic-api-key] (llm.settings/llm-anthropic-api-key! " sk-ant-abc123 ") (is (= "sk-ant-abc123" (llm.settings/llm-anthropic-api-key)))))) - (testing "rejects keys without sk-ant- prefix" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid Anthropic API key format" (llm.settings/llm-anthropic-api-key! "invalid-key")))) - (testing "empty/nil clears the setting" (mt/with-temp-env-var-value! [mb-llm-anthropic-api-key nil] (mt/discard-setting-changes [llm-anthropic-api-key] @@ -34,7 +32,6 @@ (testing "returns false when no API key is set" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil)] (is (false? (llm.settings/llm-anthropic-api-key-configured?))))) - (testing "returns true when API key is set" (mt/with-temporary-setting-values [llm-anthropic-api-key "sk-ant-test"] (is (true? (llm.settings/llm-anthropic-api-key-configured?)))))) @@ -49,7 +46,6 @@ (testing "returns default (nil) when :metabase-ai-managed feature is not enabled, even if a value is set" (mt/with-premium-features #{} (is (nil? (llm.settings/llm-proxy-base-url)))))))) - (testing "can be set and read when :metabot-v3 feature is enabled" (mt/with-premium-features #{:metabot-v3} (mt/with-temporary-setting-values [llm-proxy-base-url "https://proxy.example"] @@ -57,7 +53,6 @@ (testing "returns default (nil) when neither managed-ai feature is enabled, even if a value is set" (mt/with-premium-features #{} (is (nil? (llm.settings/llm-proxy-base-url)))))))) - (testing "cannot be set when neither managed-ai feature is enabled" (mt/with-premium-features #{} (is (thrown-with-msg? @@ -73,7 +68,6 @@ (testing "returns default (nil) when :metabase-ai-managed feature is not enabled, even if a value is set" (mt/with-premium-features #{} (is (nil? (llm.settings/ai-service-base-url)))))))) - (testing "can be set and read when :metabot-v3 feature is enabled" (mt/with-premium-features #{:metabot-v3} (mt/with-temporary-setting-values [ai-service-base-url "https://ai-service.example"] @@ -81,7 +75,6 @@ (testing "returns default (nil) when neither managed-ai feature is enabled, even if a value is set" (mt/with-premium-features #{} (is (nil? (llm.settings/ai-service-base-url)))))))) - (testing "cannot be set when neither managed-ai feature is enabled" (mt/with-premium-features #{} (is (thrown-with-msg? @@ -95,7 +88,6 @@ (testing "default value is 4096" (mt/with-temporary-setting-values [llm-max-tokens nil] (is (= 4096 (llm.settings/llm-max-tokens))))) - (testing "can be overridden" (mt/with-temporary-setting-values [llm-max-tokens 8192] (is (= 8192 (llm.settings/llm-max-tokens)))))) @@ -104,7 +96,6 @@ (testing "default value is 60000 (60 seconds)" (mt/with-temporary-setting-values [llm-request-timeout-ms nil] (is (= 60000 (llm.settings/llm-request-timeout-ms))))) - (testing "can be overridden" (mt/with-temporary-setting-values [llm-request-timeout-ms 120000] (is (= 120000 (llm.settings/llm-request-timeout-ms)))))) @@ -113,7 +104,6 @@ (testing "default value is 5000 (5 seconds)" (mt/with-temporary-setting-values [llm-connection-timeout-ms nil] (is (= 5000 (llm.settings/llm-connection-timeout-ms))))) - (testing "can be overridden" (mt/with-temporary-setting-values [llm-connection-timeout-ms 10000] (is (= 10000 (llm.settings/llm-connection-timeout-ms)))))) @@ -122,7 +112,6 @@ (testing "default value is 20 requests per minute" (mt/with-temporary-setting-values [llm-rate-limit-per-user nil] (is (= 20 (llm.settings/llm-rate-limit-per-user))))) - (testing "can be overridden" (mt/with-temporary-setting-values [llm-rate-limit-per-user 50] (is (= 50 (llm.settings/llm-rate-limit-per-user)))))) @@ -131,7 +120,6 @@ (testing "default value is 100 requests per minute" (mt/with-temporary-setting-values [llm-rate-limit-per-ip nil] (is (= 100 (llm.settings/llm-rate-limit-per-ip))))) - (testing "can be overridden" (mt/with-temporary-setting-values [llm-rate-limit-per-ip 200] (is (= 200 (llm.settings/llm-rate-limit-per-ip)))))) diff --git a/test/metabase/logger/api_test.clj b/test/metabase/logger/api_test.clj index 91a2dd724c61..66fd5296103c 100644 --- a/test/metabase/logger/api_test.clj +++ b/test/metabase/logger/api_test.clj @@ -66,21 +66,18 @@ (is (= :info (logger/ns-log-level trace-ns))) (is (nil? (logger/exact-ns-logger fatal-ns))) (is (nil? (logger/exact-ns-logger other-ns)))) - (testing "overriding multiple namespaces works" (mt/user-http-request :crowberto :post 204 "logger/adjustment" {:duration timeout-ms, :duration_unit :milliseconds, :log_levels log-levels}) (is (= :trace (logger/ns-log-level trace-ns))) (is (= :fatal (logger/ns-log-level fatal-ns))) (is (nil? (logger/exact-ns-logger other-ns)))) - (testing "a new override cancels the previous one" (mt/user-http-request :crowberto :post 204 "logger/adjustment" {:duration timeout-ms, :duration_unit :milliseconds, :log_levels {other-ns :trace}}) (is (= :info (logger/ns-log-level trace-ns))) (is (nil? (logger/exact-ns-logger fatal-ns))) (is (= :trace (logger/ns-log-level other-ns)))) - (testing "the override is automatically undone when the timeout is reached" (let [limit (+ (System/currentTimeMillis) timeout-ms 5000)] (loop [] @@ -95,13 +92,11 @@ :else (is (nil? (logger/exact-ns-logger other-ns)) "the change has not been undone automatically"))))) - (testing "empty adjustment works" (mt/user-http-request :crowberto :post 204 "logger/adjustment" {:duration timeout-ms, :duration_unit :milliseconds, :log_levels {}}) (is (= :info (logger/ns-log-level trace-ns))) (is (nil? (logger/exact-ns-logger fatal-ns))) - (testing "empty adjustment works a second time too" (mt/user-http-request :crowberto :post 204 "logger/adjustment" {:duration timeout-ms, :duration_unit :milliseconds, :log_levels {}}) @@ -124,17 +119,14 @@ {:duration timeout-hours, :duration_unit :hours, :log_levels log-levels}) (is (= :trace (logger/ns-log-level trace-ns))) (is (= :fatal (logger/ns-log-level fatal-ns)))) - (testing "only admins can delete" (mt/user-http-request :lucky :delete 403 "logger/adjustment") (is (= :trace (logger/ns-log-level trace-ns))) (is (= :fatal (logger/ns-log-level fatal-ns)))) - (testing "delete undoes the adjustments" (mt/user-http-request :crowberto :delete 204 "logger/adjustment") (is (= :info (logger/ns-log-level trace-ns))) (is (nil? (logger/exact-ns-logger fatal-ns)))) - (testing "second delete is OK" (mt/user-http-request :crowberto :delete 204 "logger/adjustment") (is (= :info (logger/ns-log-level trace-ns))) diff --git a/test/metabase/logger/core_test.clj b/test/metabase/logger/core_test.clj index 93a67398ddfd..08b5243a0871 100644 --- a/test/metabase/logger/core_test.clj +++ b/test/metabase/logger/core_test.clj @@ -32,15 +32,12 @@ entry)) (logger/messages)) "In memory ring buffer did not receive log message"))) - (testing "set isAdditive = false if parent logger is root to prevent logging to console (#26468)" (testing "make sure it's true to starts with" (is (.isAdditive (logger 'metabase)))) - (testing "set to false if parent logger is root" (mt/with-log-level :warn (is (not (.isAdditive (logger 'metabase)))))) - (testing "still true if the parent logger is not root" (mt/with-log-level [metabase.logger.core :warn] (is (.isAdditive (logger 'metabase.logger.core))))))) diff --git a/test/metabase/login_history/api_test.clj b/test/metabase/login_history/api_test.clj index 4c74f931d913..73587d1228f5 100644 --- a/test/metabase/login_history/api_test.clj +++ b/test/metabase/login_history/api_test.clj @@ -44,7 +44,7 @@ :device_id device-id :device_description windows-user-agent :ip_address "52.206.149.9"} - ;; this one shouldn't show up because it's from a different User + ;; this one shouldn't show up because it's from a different User :model/LoginHistory _ {:timestamp #t "2021-03-17T19:00Z" :user_id (mt/user->id :rasta) :device_id device-id diff --git a/test/metabase/login_history/models/login_history_test.clj b/test/metabase/login_history/models/login_history_test.clj index 27008a6b34fa..3655851ce676 100644 --- a/test/metabase/login_history/models/login_history_test.clj +++ b/test/metabase/login_history/models/login_history_test.clj @@ -50,25 +50,20 @@ :model/User {other-user-id :id} {}] (testing "false when the user has no prior first-device events" (is (false? (#'login-history/too-many-new-device-emails-recently? user-id)))) - (testing "false at the cap (comparison is strict greater-than)" (insert-login-histories! (new-devices user-id cap)) (is (false? (#'login-history/too-many-new-device-emails-recently? user-id)))) - (testing "true when the user is past the cap" (insert-login-histories! (new-devices user-id 1)) (is (true? (#'login-history/too-many-new-device-emails-recently? user-id)))) - (testing "repeated logins from the same device count as one first-device event" (mt/with-temp [:model/User {repeat-user-id :id} {}] (insert-login-histories! (repeat 20 {:user_id repeat-user-id :device_id (str (random-uuid))})) (is (false? (#'login-history/too-many-new-device-emails-recently? repeat-user-id))))) - (testing "another user's activity does not count toward this user's limit" (insert-login-histories! (new-devices other-user-id 10)) (mt/with-temp [:model/User {fresh-user-id :id} {}] (is (false? (#'login-history/too-many-new-device-emails-recently? fresh-user-id))))) - (testing "events outside the rate-limit window do not count" (mt/with-temp [:model/User {old-user-id :id} {}] (let [two-days-ago (t/minus (t/offset-date-time) (t/days 2))] diff --git a/test/metabase/mcp/api_test.clj b/test/metabase/mcp/api_test.clj index 8274d014e5a8..37b1d3960d4c 100644 --- a/test/metabase/mcp/api_test.clj +++ b/test/metabase/mcp/api_test.clj @@ -2,6 +2,7 @@ (:require [clojure.string :as str] [clojure.test :refer :all] + [clojure.walk :as walk] [metabase.agent-api.settings :as agent-api.settings] [metabase.api.macros.scope :as scope] [metabase.lib.core :as lib] @@ -92,7 +93,10 @@ (defn- call-tool "Call an MCP tool within an initialized session. Returns the parsed MCP result content (the JSON-decoded text from the first content block). - Records test failures if the response status is not 200 or the tool returns an error." + Records test failures if the response status is not 200 or the tool returns an error. + Also enforces the MCP spec contract: every successful tool result with content + must include `structuredContent` (because every tool in our manifest declares + `outputSchema`, and the spec mandates structuredContent for those)." [session-id tool-name arguments] (let [response (mcp-request (jsonrpc-request "tools/call" {:name tool-name :arguments arguments}) @@ -103,6 +107,9 @@ (is (not (:isError result)) (str "Tool " tool-name " error: " (some-> result :content first :text))) (when-not (:isError result) + (is (contains? result :structuredContent) + (str "Tool " tool-name " declared an outputSchema in tools/list but did " + "not return structuredContent — MCP spec violation.")) (json/decode+kw (:text (first (:content result))))))) ;;; ---------------------------------------------------- Tests ----------------------------------------------------- @@ -114,6 +121,38 @@ (is (= 401 (:status response))) (is (= -32603 (get-in response [:body :error :code])))))) +(deftest origin-validation-test + (testing "cross-origin browser requests are rejected when the origin is not configured" + (let [response (mcp-request (jsonrpc-request "initialize") + {"host" "mbtest.poom.dev" + "origin" "http://127.0.0.1:6274"})] + (is (= 403 (:status response))) + (is (= "Origin not allowed" (get-in response [:body :error :message]))))) + (testing "cross-origin browser requests are accepted for configured MCP client origins" + (mt/with-temporary-setting-values [mcp.settings/mcp-apps-cors-custom-origins "http://127.0.0.1:6274"] + (let [response (mcp-request (jsonrpc-request "initialize") + {"host" "mbtest.poom.dev" + "origin" "http://127.0.0.1:6274"})] + (is (= 200 (:status response))) + (is (some? (get-in response [:headers "Mcp-Session-Id"])))))) + (testing "same-origin requests with bracketed IPv6 hosts are accepted" + (let [response (mcp-request (jsonrpc-request "initialize") + {"host" "[::1]:3000" + "origin" "http://[::1]:3000"})] + (is (= 200 (:status response))))) + (testing "same-origin requests with mixed-case host/origin are accepted" + (let [response (mcp-request (jsonrpc-request "initialize") + {"host" "Example.com" + "origin" "https://example.COM"})] + (is (= 200 (:status response))))) + (testing "approved MCP origins match the Origin header case-insensitively" + (mt/with-temporary-setting-values [mcp.settings/mcp-apps-cors-custom-origins "https://Example.COM"] + (let [response (mcp-request (jsonrpc-request "initialize") + {"host" "mbtest.poom.dev" + "origin" "HTTPS://example.com"})] + (is (= 200 (:status response))) + (is (some? (get-in response [:headers "Mcp-Session-Id"]))))))) + (deftest mcp-enabled-setting-test (testing "external MCP requests return 403 when disabled" (mt/with-temporary-setting-values [mcp.settings/mcp-enabled? false] @@ -138,7 +177,7 @@ (is (= 1 (get-in response [:body :id]))) (let [result (get-in response [:body :result])] (is (= "2025-03-26" (:protocolVersion result))) - (is (= {:tools {} :resources {}} (:capabilities result))) + (is (= {:tools {:listChanged true} :resources {}} (:capabilities result))) (is (= {:name "metabase" :version "0.1.0"} (:serverInfo result))))))) (deftest notifications-initialized-test @@ -170,6 +209,68 @@ (is (= 200 (:status delete-response))) (is (= 200 (:status post-response)))))) +(deftest tools-list-all-tools-declare-required-hints-test + (testing "every tool advertises readOnlyHint, destructiveHint, openWorldHint (some MCP clients reject tools that omit them)" + (let [[session-id _] (initialize!) + response (mcp-request (jsonrpc-request "tools/list") {"mcp-session-id" session-id}) + tools (get-in response [:body :result :tools])] + (is (seq tools) "tools/list should return at least one tool") + (doseq [{:keys [name annotations]} tools] + (is (contains? annotations :readOnlyHint) (str name " missing :readOnlyHint")) + (is (contains? annotations :destructiveHint) (str name " missing :destructiveHint")) + (is (contains? annotations :openWorldHint) (str name " missing :openWorldHint")))))) + +(deftest text-content-includes-structured-content-for-maps-test + (testing "text-content emits structuredContent for map values — MCP spec requires it for tools with outputSchema" + (let [text-content (var-get #'mcp.tools/text-content)] + (testing "map → both content and structuredContent" + (let [result (text-content {:foo "bar"})] + (is (= {:foo "bar"} (:structuredContent result))) + (is (= "{\"foo\":\"bar\"}" (-> result :content first :text))))) + (testing "sequential → structuredContent is the collection" + (is (= [1 2 3] (:structuredContent (text-content [1 2 3]))))) + (testing "string → no structuredContent (nothing to structure)" + (let [result (text-content "ok")] + (is (not (contains? result :structuredContent))) + (is (= "ok" (-> result :content first :text)))))))) + +(deftest tool-result-emits-structured-content-test + (testing "tools that declare outputSchema emit structuredContent — guards the regression where Claude Desktop got 500s because we declared outputSchema without matching structuredContent" + (let [[session-id _] (initialize!) + response (mcp-request (jsonrpc-request "tools/call" + {:name "get_table" + :arguments {:id (mt/id :orders)}}) + {"mcp-session-id" session-id}) + result (get-in response [:body :result])] + (is (= 200 (:status response))) + (is (not (:isError result)) + (str "get_table should succeed: " (some-> result :content first :text))) + (is (contains? result :structuredContent) + "get_table declares outputSchema → MUST emit structuredContent") + (is (map? (:structuredContent result)) + "structuredContent should be the parsed response object, not a string") + (is (= "ORDERS" (-> result :structuredContent :name)) + "structuredContent should mirror the endpoint response shape")))) + +(deftest tools-list-strict-shape-test + (testing "no tool's inputSchema uses JSON-Schema constructs that ChatGPT's strict MCP validator rejects" + ;; Pins the strict-shape guarantee across the whole exposed tool surface. `construct_query` had + ;; this asserted before; broadening it to every tool catches drift in any wire schema (e.g. + ;; `query`'s `:multi` body referencing agent-lib `:tuple` schemas) that would silently leak + ;; `:allOf`/`:prefixItems`/`items:false` constructs into a tool that worked before the regression. + (let [[session-id _] (initialize!) + response (mcp-request (jsonrpc-request "tools/list") {"mcp-session-id" session-id}) + tools (get-in response [:body :result :tools])] + (is (pos? (count tools))) + (doseq [{:keys [name inputSchema]} tools] + (let [schema-keys (atom #{})] + (walk/postwalk (fn [x] (when (map? x) (swap! schema-keys into (keys x))) x) inputSchema) + (is (empty? (select-keys (frequencies @schema-keys) [:allOf :prefixItems])) + (str "Tool " name " inputSchema contains :allOf or :prefixItems")) + (is (not (some #(false? (:items %)) + (->> (tree-seq coll? seq inputSchema) (filter map?)))) + (str "Tool " name " inputSchema contains `items: false` (tuple closure)"))))))) + (deftest tools-list-test (testing "tools/list returns the 10 agent tools" (let [[session-id _] (initialize!) @@ -205,7 +306,24 @@ (is (contains? (leaf-types (property-schema "search" "term_queries")) "array")) (is (= "string" (get-in (array-branch (property-schema "search" "term_queries")) [:items :type]))) (is (contains? (leaf-types (property-schema "search" "semantic_queries")) "array")) - (is (= "string" (get-in (array-branch (property-schema "search" "semantic_queries")) [:items :type])))))))) + (is (= "string" (get-in (array-branch (property-schema "search" "semantic_queries")) [:items :type]))))) + (testing "construct_query inputSchema is ChatGPT-strict (no allOf/prefixItems/items:false)" + (let [tools-by-name (into {} (map (juxt :name identity)) tools) + construct-query-schema (:inputSchema (get tools-by-name "construct_query")) + schema-keys (atom #{})] + (walk/postwalk (fn [x] + (when (map? x) + (swap! schema-keys into (keys x))) + x) + construct-query-schema) + (is (= false (:additionalProperties construct-query-schema))) + ;; ChatGPT's MCP validator rejects exactly these JSON-Schema constructs. + (is (empty? (select-keys (frequencies @schema-keys) + [:allOf :prefixItems]))) + ;; `items: false` (tuple closure) must not appear either. + (is (not (some #(false? (:items %)) + (->> (tree-seq coll? seq construct-query-schema) + (filter map?)))))))))) (deftest ping-test (testing "ping returns empty result" @@ -261,7 +379,6 @@ (let [search-data (json/decode+kw (:text (first (:content result))))] (is (contains? search-data :data)) (is (contains? search-data :total_count)))))) - (testing "search accepts a singleton string as a one-element query list" (search.tu/with-legacy-search (let [[session-id _] (initialize!) @@ -274,7 +391,6 @@ (is (nil? (:isError result))) (let [search-data (json/decode+kw (:text (first (:content result))))] (is (contains? search-data :data)))))) - (testing "search coerces JSON-stringified arrays so clients that serialize args through a string layer still work" (search.tu/with-legacy-search (let [[session-id _] (initialize!) @@ -303,7 +419,6 @@ (is (string? (:body response))) (is (str/includes? (:body response) "event: message")) (is (str/includes? (:body response) "data: ")))) - (testing "POST without Accept: text/event-stream returns JSON (backward-compatible)" (let [[session-id _] (initialize!) response (mcp-request (jsonrpc-request "ping") @@ -368,7 +483,6 @@ {"mcp-session-id" session-id})] (is (= 200 (:status list-response))) (is (some? (get-in list-response [:body :result :tools]))))) - (testing "notifications/initialized remains accepted for compatibility" (let [response (mcp-request (jsonrpc-request "initialize")) session-id (get-in response [:headers "Mcp-Session-Id"])] @@ -406,7 +520,6 @@ result (get-in response [:body :result])] (is (= 200 (:status response))) (is (true? (:isError result))))) - (testing "tools/call with missing path params returns an error" (let [[session-id _] (initialize!) response (mcp-request (jsonrpc-request "tools/call" @@ -698,7 +811,6 @@ (testing "tools/list with unrestricted scopes returns all tools" (let [tools (mcp.tools/list-tools #{::scope/unrestricted})] (is (= 10 (count tools))))) - (testing "tools/list with specific scope only returns matching tools" (let [tools (mcp.tools/list-tools #{"agent:search"}) tool-names (set (map :name tools))] @@ -707,15 +819,12 @@ ;; Should NOT include tools with other scopes (is (not (contains? tool-names "get_table"))) (is (not (contains? tool-names "construct_query"))))) - (testing "tools/list with wildcard scope matches all agent tools" (let [tools (mcp.tools/list-tools #{"agent:*"})] (is (= 10 (count tools))))) - (testing "tools/list with nil scopes returns all tools" (let [tools (mcp.tools/list-tools nil)] (is (= 10 (count tools))))) - (testing "tools/list with empty scopes does not return all tools" (let [tools (mcp.tools/list-tools #{})] (is (zero? (count tools)) diff --git a/test/metabase/mcp/tools_test.clj b/test/metabase/mcp/tools_test.clj new file mode 100644 index 000000000000..4d8755bbfb5b --- /dev/null +++ b/test/metabase/mcp/tools_test.clj @@ -0,0 +1,96 @@ +(ns metabase.mcp.tools-test + "scope-matches? tests moved to [[metabase.mcp.scope-test]]." + (:require + [clojure.test :refer :all] + [clojure.walk :as walk] + [metabase.api.macros.scope :as api.scope] + [metabase.mcp.tools :as mcp.tools] + [metabase.util.json :as json])) + +(deftest ^:parallel drop-nil-args-test + (testing "strips nil-valued top-level keys so 'missing' and 'explicit nil' are equivalent at the MCP boundary" + (let [drop-nil-args (var-get #'mcp.tools/drop-nil-args)] + (testing "removes top-level nils" + (is (= {:id 42 :flag false} + (drop-nil-args {:id 42 :flag false :extra nil})))) + (testing "keeps non-nil falsey values" + (is (= {:flag false :count 0 :text ""} + (drop-nil-args {:flag false :count 0 :text ""})))) + (testing "preserves nested nils (only top-level is rewritten)" + (is (= {:source {:type "table" :id nil}} + (drop-nil-args {:source {:type "table" :id nil} :continuation_token nil})))) + (testing "nil argument → nil" + (is (nil? (drop-nil-args nil)))) + (testing "empty map stays empty" + (is (= {} (drop-nil-args {}))))))) + +(deftest ^:parallel overrides-cover-known-tools-test + (testing "every key in `mcp-input-overrides` matches a real tool" + ;; Catches the silent-no-op failure mode if a tool gets renamed or an override key is misspelled: + ;; the override would otherwise be dropped on the floor and the wire-shape schema would be published + ;; in place of the intended MCP shape. Lives as a test (not a runtime check at manifest generation) + ;; so prod startup isn't burdened with a check that only catches developer mistakes. + (let [manifest-fn (#'mcp.tools/manifest) + tool-names (into #{} (map :name) (:tools manifest-fn))] + (doseq [[label override-map-var] [["mcp-input-overrides" #'mcp.tools/mcp-input-overrides]]] + (testing label + (let [unknown (remove tool-names (keys @override-map-var))] + (is (empty? unknown) + (str label " has keys that don't match any tool name: " (vec unknown))))))))) + +(defn- data-node? + "True if `x` is a value type our published JSON-Schema-shaped tool schemas + are allowed to contain. Visited via `walk/postwalk`, which traverses every + node (branches and leaves), so this predicate must accept both scalars and + the container types that hold them. Excludes functions, vars, atoms — + anything that would blow up `json/encode` or whose JSON encoding isn't + byte-stable. + + `java.util.regex.Pattern` is allowed: `mjs/transform` lowers `[:re #\"...\"]` + to `{:type \"string\" :pattern #\"...\"}`, leaving the compiled Pattern in + the schema. Note that `(hash Pattern)` is identity-based and unstable, but + `(json/encode Pattern)` writes the regex source string — which is stable. + `tools-hash` hashes the JSON bytes, never the Pattern object, so Pattern + nodes are safe in practice." + [x] + (or (nil? x) (boolean? x) (number? x) (string? x) + (keyword? x) (symbol? x) + (map? x) (vector? x) (seq? x) (set? x) + (instance? java.util.regex.Pattern x))) + +(deftest ^:parallel tool-schemas-are-pure-data-test + (testing "published tool schemas must be JSON-encodable pure data so `tools-hash` is stable" + ;; The MCP SSE keepalive emits `notifications/tools/list_changed` when `tools-hash` changes. + ;; That hash is only meaningful if `inputSchema` / `outputSchema` are pure data: a stray fn + ;; (e.g. a Malli predicate that leaked through `mjs/transform`) would either blow up + ;; `json/encode` or, if hashed directly, produce an identity-hash that's stable within a JVM + ;; but not across builds — silently breaking the list-changed contract. + (let [tools (mcp.tools/list-tools #{::api.scope/unrestricted})] + (is (seq tools) "list-tools should return some tools under the unrestricted scope") + (doseq [{tool-name :name :keys [inputSchema outputSchema]} tools + [label schema] [[:inputSchema inputSchema] [:outputSchema outputSchema]] + :when schema] + (testing (str tool-name " " label) + (testing "every node is a pure-data type" + (walk/postwalk + (fn [x] + (is (data-node? x) + (str "Non-data node in " tool-name " " label ": " (pr-str x) " (" (type x) ")")) + x) + schema)) + (testing "json-encodes without error" + (is (string? (json/encode schema)))) + (testing "json encoding is deterministic" + (is (= (json/encode schema) (json/encode schema))))))))) + +(deftest ^:parallel tools-hash-test + (testing "tools-hash is deterministic across calls" + (let [scopes #{::api.scope/unrestricted}] + (is (= (mcp.tools/tools-hash scopes) (mcp.tools/tools-hash scopes))))) + (testing "tools-hash differs when the visible toolset differs" + ;; Different scope set yields a different (possibly smaller) tool list, so the hash should change. + ;; If this ever flakes because two scope filters happen to produce identical lists, narrow the + ;; second scope to one that we know filters tools out. + (let [unrestricted (mcp.tools/tools-hash #{::api.scope/unrestricted}) + empty-scopes (mcp.tools/tools-hash #{})] + (is (not= unrestricted empty-scopes))))) diff --git a/test/metabase/measures/api_test.clj b/test/metabase/measures/api_test.clj index b1c797c22049..895509c20af9 100644 --- a/test/metabase/measures/api_test.clj +++ b/test/metabase/measures/api_test.clj @@ -39,7 +39,6 @@ (deftest authentication-test (is (= (get api.response/response-unauthentic :body) (client/client :get 401 "measure"))) - (is (= (get api.response/response-unauthentic :body) (client/client :put 401 "measure/13")))) @@ -57,18 +56,14 @@ (testing "POST /api/measure" (is (=? {:errors {:name "value must be a non-blank string."}} (mt/user-http-request :crowberto :post 400 "measure" {}))) - (is (=? {:errors {:table_id "value must be an integer greater than zero."}} (mt/user-http-request :crowberto :post 400 "measure" {:name "abc"}))) - (is (=? {:errors {:table_id "value must be an integer greater than zero."}} (mt/user-http-request :crowberto :post 400 "measure" {:name "abc" :table_id "foobar"}))) - (is (=? {:errors {:definition "Value must be a map."}} (mt/user-http-request :crowberto :post 400 "measure" {:name "abc" :table_id 123}))) - (is (=? {:errors {:definition "Value must be a map."}} (mt/user-http-request :crowberto :post 400 "measure" {:name "abc" :table_id 123 @@ -108,14 +103,11 @@ (testing "PUT /api/measure/:id" (is (=? {:errors {:name "nullable value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "measure/1" {:name "" :revision_message "abc"}))) - (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "measure/1" {:name "abc"}))) - (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "measure/1" {:name "abc" :revision_message ""}))) - (is (=? {:errors {:definition "nullable map"}} (mt/user-http-request :crowberto :put 400 "measure/1" {:name "abc" :revision_message "123" diff --git a/test/metabase/measures/models/measure_test.clj b/test/metabase/measures/models/measure_test.clj index 8841dea50490..548cb03c11e0 100644 --- a/test/metabase/measures/models/measure_test.clj +++ b/test/metabase/measures/models/measure_test.clj @@ -82,14 +82,12 @@ Exception #"[Cc]ycle" (t2/update! :model/Measure measure-1-id {:definition (measure-definition-referencing measure-2-id)}))))) - ;;; ------------------------------------------------ Metric Reference Tests ------------------------------------------------ (defn- metric-query "Create a metric-style dataset query (a query with a single aggregation)." [] (measure-definition (lib/count))) - (deftest insert-measure-with-metric-reference-test (testing "Inserting a measure that references a metric should fail" (mt/with-temp [:model/Card {metric-id :id} {:name "Test Metric" @@ -106,7 +104,6 @@ :table_id (mt/id :venues) :creator_id (mt/user->id :rasta) :definition (measure-definition (lib.metadata/metric mp metric-id))}))))))) - (deftest insert-measure-with-nested-metric-reference-test (testing "Inserting a measure with metric nested in arithmetic expression should fail" (mt/with-temp [:model/Card {metric-id :id} {:name "Test Metric" @@ -122,11 +119,10 @@ {:name "Bad Measure" :table_id (mt/id :venues) :creator_id (mt/user->id :rasta) - ;; Metric nested in an arithmetic expression: metric + 1 + ;; Metric nested in an arithmetic expression: metric + 1 :definition (measure-definition (lib/+ (lib.metadata/metric mp metric-id) 1))}))))))) - (deftest update-measure-with-metric-reference-test (testing "Updating a measure to reference a metric should fail" (mt/with-temp [:model/Measure {measure-id :id} {:name "Good Measure" diff --git a/test/metabase/metabot/agent/core_test.clj b/test/metabase/metabot/agent/core_test.clj index 5448b90b5137..423ad919280e 100644 --- a/test/metabase/metabot/agent/core_test.clj +++ b/test/metabase/metabot/agent/core_test.clj @@ -37,17 +37,14 @@ (testing "continues when iteration < max and has tool calls" (is (#'agent/should-continue? 0 max-iter [{:type :tool-input}])) (is (#'agent/should-continue? 1 max-iter [{:type :tool-input}]))) - (testing "continues when text AND tool calls present (LLM thinking aloud)" (is (#'agent/should-continue? 0 max-iter [{:type :tool-input} {:type :text}])) (is (#'agent/should-continue? 0 max-iter [{:type :text} {:type :tool-input}]))) - (testing "stops at max iterations (1-based: iteration >= max means done)" (is (not (#'agent/should-continue? 3 max-iter [{:type :tool-input}]))) (is (not (#'agent/should-continue? 4 max-iter [{:type :tool-input}])))) - (testing "stops when no tool calls (text-only is final answer)" (is (not (#'agent/should-continue? 0 max-iter [{:type :text}]))) (is (not (#'agent/should-continue? 0 max-iter [{:type :usage}]))) @@ -70,7 +67,6 @@ (is (pos? (count result))) ;; Should have state data (final part) (is (some #(= :data (:type %)) result))))) - (testing "sql profile requests required tool choice" (let [captured (atom nil)] (with-redefs [self/call-llm (fn [_model _system _parts _tools _tracking-opts llm-opts] @@ -83,11 +79,10 @@ :profile-id :sql :context {}})) (is (= {:tool-choice "required"} @captured))))) - (testing "runs agent loop with tool execution" (let [call-count (atom 0)] (with-redefs [openrouter/openrouter (fn [_] - ;; First call returns tool-input, second returns text + ;; First call returns tool-input, second returns text (let [n (swap! call-count inc)] (if (= 1 n) (mut/mock-llm-response @@ -108,7 +103,6 @@ (is (some #(= :data (:type %)) result)) ;; Should have tool-related parts (is (some #(= :tool-input (:type %)) result)))))) - (testing "handles errors gracefully" (with-redefs [openrouter/openrouter (fn [_] (throw (ex-info "Mock error" {})))] @@ -131,14 +125,12 @@ :query {:database 1 :type :query :query {:source-table 1}}}]} seeded (#'agent/seed-state {} context)] (is (contains? (get seeded :queries) "query-123")))) - (testing "does not seed native SQL string queries" (let [context {:user_is_viewing [{:type "native" :id "query-456" :query "SELECT * FROM users"}]} seeded (#'agent/seed-state {} context)] (is (empty? (get seeded :queries))))) - (testing "ignores viewing items without ids or queries" (let [context {:user_is_viewing [{:type "native" :query {:database 1}} {:type "adhoc" :id "no-query"}]} @@ -184,7 +176,6 @@ memory {:state {:queries {} :charts {}}} updated (#'agent/extract-queries memory parts)] (is (= query (get-in (memory/get-state updated) [:queries "q-123"]))))) - (testing "ignores parts without structured-output" (let [parts [{:type :tool-output :id "t1" @@ -193,7 +184,6 @@ memory {:state {:queries {} :charts {}}} updated (#'agent/extract-queries memory parts)] (is (empty? (:queries (memory/get-state updated)))))) - (testing "ignores non-tool-output parts" (let [parts [{:type :text :text "hello"} {:type :tool-input :id "t1" :function "search"}] @@ -255,19 +245,19 @@ (mt/as-admin (mt/with-temporary-setting-values [llm-metabot-provider test-provider] (testing "Scenario 1: Search → Query → Chart (multi-turn happy path)" - ;; User asks: "Show me the first 10 orders" - ;; - Iteration 1: LLM calls search tool to find orders table - ;; - Iteration 2: LLM calls construct_notebook_query to create a raw query - ;; - Iteration 3: LLM returns text with chart link - ;; - ;; We use real tools with only the search backend and LLM mocked. - ;; The construct_notebook_query tool runs real query construction against test DB. - ;; We use a simple "raw" query type that doesn't require field IDs. + ;; User asks: "Show me the first 10 orders" + ;; - Iteration 1: LLM calls search tool to find orders table + ;; - Iteration 2: LLM calls construct_notebook_query to create a raw query + ;; - Iteration 3: LLM returns text with chart link + ;; + ;; We use real tools with only the search backend and LLM mocked. + ;; The construct_notebook_query tool runs real query construction against test DB. + ;; We use a simple "raw" query type that doesn't require field IDs. (mt/with-current-user (mt/user->id :crowberto) (let [orders-table-id (mt/id :orders) - ;; Track LLM calls + ;; Track LLM calls llm-call-count (atom 0) - ;; Scripted LLM responses - uses real table ID from test DB + ;; Scripted LLM responses - uses real table ID from test DB llm-responses [;; Iteration 1: Search for orders table [{:type :start :id "msg-1"} @@ -278,7 +268,7 @@ :keyword_queries ["orders"] :entity_types ["table"]}} {:type :usage :usage {:promptTokens 100 :completionTokens 20} :model "test" :id "msg-1"}] - ;; Iteration 2: Construct a simple query via agent-lib program + ;; Iteration 2: Construct a simple query via agent-lib program [{:type :start :id "msg-2"} {:type :tool-input :id "call-construct-1" @@ -289,13 +279,13 @@ :operations [["limit" 10]]} :visualization {:chart_type "table"}}} {:type :usage :usage {:promptTokens 200 :completionTokens 30} :model "test" :id "msg-2"}] - ;; Iteration 3: Final text response + ;; Iteration 3: Final text response [{:type :start :id "msg-3"} {:type :text :text "Here are the first 10 orders from the orders table."} {:type :usage :usage {:promptTokens 300 :completionTokens 10} :model "test" :id "msg-3"}]]] - ;; Mock only openrouter/openrouter (LLM) and metabot-search/search (search backend) - ;; Everything else runs real code + ;; Mock only openrouter/openrouter (LLM) and metabot-search/search (search backend) + ;; Everything else runs real code (with-redefs [openrouter/openrouter (fn [_opts] (let [n (swap! llm-call-count inc)] (mut/mock-llm-response (get llm-responses (dec n) [])))) @@ -309,24 +299,24 @@ (testing "Should successfully go through 3 iterations" (is (=? [{:type :start} {:type :tool-input :function "search"} - ;; Cumulative usage after iteration 1: 100 prompt, 20 completion + ;; Cumulative usage after iteration 1: 100 prompt, 20 completion {:type :usage :usage {:promptTokens 100 :completionTokens 20}} {:type :tool-output :function "search" :result {:structured-output {:total_count 1}}} {:type :start} {:type :tool-input :function "construct_notebook_query"} - ;; Cumulative usage after iteration 2: 100+200=300 prompt, 20+30=50 completion + ;; Cumulative usage after iteration 2: 100+200=300 prompt, 20+30=50 completion {:type :usage :usage {:promptTokens 300 :completionTokens 50}} - ;; references real db id + ;; references real db id {:type :tool-output :function "construct_notebook_query" :result {:structured-output {:query {:database (mt/id)}}}} {:type :data :data-type "navigate_to"} {:type :start} - ;; has final text part + ;; has final text part {:type :text} - ;; Cumulative usage after iteration 3: 300+300=600 prompt, 50+10=60 completion + ;; Cumulative usage after iteration 3: 300+300=600 prompt, 50+10=60 completion {:type :usage :usage {:promptTokens 600 :completionTokens 60}} {:type :data :data-type "state" @@ -353,7 +343,7 @@ (fn [_] (let [n (swap! call-count inc)] (case (int n) - ;; Iteration 1: tool call with usage + ;; Iteration 1: tool call with usage 1 (mut/mock-llm-response [{:type :start :id "msg-1"} {:type :tool-input @@ -362,7 +352,7 @@ :arguments {:query "test"}} {:type :usage :usage {:promptTokens 100 :completionTokens 20} :model "gpt-4" :id "msg-1"}]) - ;; Iteration 2: text response with usage + ;; Iteration 2: text response with usage (mut/mock-llm-response [{:type :start :id "msg-2"} {:type :text :text "Done"} @@ -383,7 +373,6 @@ (testing "second usage is cumulative (iteration 1 + 2)" (is (= {:promptTokens 250 :completionTokens 50} (:usage (second usages))))))))) - (testing "cumulative usage works across multiple models" (let [call-count (atom 0)] (with-redefs [openrouter/openrouter @@ -480,14 +469,12 @@ {:profile-id "internal"})))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/agent-duration-ms {:profile-id "internal"}))))) - - ;; clear! is much faster than a new mt/with-prometheus-system! + ;; clear! is much faster than a new mt/with-prometheus-system! (analytics/clear! :metabase-metabot/agent-requests) (analytics/clear! :metabase-metabot/agent-iterations) (analytics/clear! :metabase-metabot/agent-errors) (analytics/clear! :metabase-metabot/agent-duration-ms) (analytics/clear! :metabase-metabot/llm-requests) - (testing "records agent-errors on failure" (with-redefs [openrouter/openrouter (fn [_] (throw (ex-info "boom" {})))] (mt/with-log-level [metabase.metabot.agent.core :fatal] @@ -545,7 +532,7 @@ :context {} :profile-id :internal :tracking-opts {:session-id "00000000-0000-0000-0000-000000000001"}}) - ;; The collector also contains token_usage events; filter for just ai_service_events. + ;; The collector also contains token_usage events; filter for just ai_service_events. (let [events (snowplow-test/pop-event-data-and-user-id!) tool-events (filter #(= "agent_used_tool" (get-in % [:data "event"])) events)] (is (=? [{:user-id (str rasta-id) @@ -558,7 +545,6 @@ "event_details" {"tool_name" "search" "step" 1}}}] tool-events))))))))) - (testing "fires 'agent_used_tool' with result=error when tool fails" (let [call-count (atom 0) rasta-id (mt/user->id :rasta)] @@ -625,7 +611,7 @@ :context {} :profile-id :internal :tracking-opts {:session-id "00000000-0000-0000-0000-000000000001"}}) - ;; Filter for just token_usage events (other events may also be present) + ;; Filter for just token_usage events (other events may also be present) (let [events (snowplow-test/pop-event-data-and-user-id!) token-events (filter #(contains? (:data %) "total_tokens") events)] (is (=? [{:user-id (str rasta-id) diff --git a/test/metabase/metabot/agent/markdown_link_buffer_test.clj b/test/metabase/metabot/agent/markdown_link_buffer_test.clj index 731ea7c7df13..d5508eb5d7b7 100644 --- a/test/metabase/metabot/agent/markdown_link_buffer_test.clj +++ b/test/metabase/metabot/agent/markdown_link_buffer_test.clj @@ -70,12 +70,10 @@ (let [[outputs flushed] (process-chunks ["Check [link" "](http://example.com)"])] (is (= ["Check " "[link](http://example.com)"] outputs)) (is (= "" flushed)))) - (testing "buffers link split across multiple chunks" (let [[outputs flushed] (process-chunks ["See [My " "Link](http:" "//example.com)"])] (is (= ["See " "" "[My Link](http://example.com)"] outputs)) (is (= "" flushed)))) - (testing "flushes incomplete link at end of stream" (let [[output flushed] (process "Incomplete [link")] (is (= "Incomplete " output)) @@ -98,12 +96,10 @@ (is (=? resolved-link-re output)) (is (not (re-find #"metabase://" output))) (is (= "" flushed)))) - (testing "falls back to link text for unknown query" (let [[output flushed] (process "[Results](metabase://query/unknown)")] (is (= "Results" output)) (is (= "" flushed)))) - (testing "incomplete chunks are also replaced well" (let [query (lib.tu/venues-query) [output flushed] (process-chunks ["Your [Res" "ults](metabase://qu" "ery/q-123)"] {"q-123" query})] @@ -119,7 +115,6 @@ (is (re-find #"\[My Chart\]\(/question#" output)) (is (not (re-find #"metabase://" output))) (is (= "" flushed)))) - (testing "falls back to link text for unknown chart" (let [[output flushed] (process "[Chart](metabase://chart/unknown)")] (is (= "Chart" output)) @@ -131,7 +126,6 @@ [output flushed] (process (str "[Users Table](metabase://table/" table-id ")"))] (is (re-find #"\[Users Table\]\(/question#.+\)" output)) (is (= "" flushed)))) - (testing "falls back to link text for unknown table" (let [[output flushed] (process "[Unknown Table](metabase://table/999999999)")] (is (= "Unknown Table" output)) @@ -142,17 +136,14 @@ (let [[output flushed] (process "[My Model](metabase://model/123)")] (is (= "[My Model](/model/123)" output)) (is (= "" flushed)))) - (testing "resolves metabase://metric links" (let [[output flushed] (process "[Revenue](metabase://metric/456)")] (is (= "[Revenue](/metric/456)" output)) (is (= "" flushed)))) - (testing "resolves metabase://dashboard links" (let [[output flushed] (process "[Sales Dashboard](metabase://dashboard/789)")] (is (= "[Sales Dashboard](/dashboard/789)" output)) (is (= "" flushed)))) - (testing "resolves metabase://transform links" (let [[output flushed] (process "[My Transform](metabase://transform/42)")] (is (= "[My Transform](/data-studio/transforms/42)" output)) @@ -172,7 +163,6 @@ (let [[output flushed] (process "[Model A](metabase://model/1) and [Model B](metabase://model/2)")] (is (= "[Model A](/model/1) and [Model B](/model/2)" output)) (is (= "" flushed)))) - (testing "handles mix of resolvable and regular links" (let [[output flushed] (process "[Model](metabase://model/1) and [Google](https://google.com)")] (is (= "[Model](/model/1) and [Google](https://google.com)" output)) @@ -186,7 +176,6 @@ (let [[output flushed] (process "")] (is (= "" output)) (is (= "" flushed)))) - (testing "resolves Slack link without link text" (let [[output flushed] (process "")] (is (= "" output)) @@ -264,7 +253,6 @@ (let [[_output _flushed registry] (process-with-registry "")] (is (= {"https://metabase.example.com/model/123" "metabase://model/123"} registry)))) - (testing "records multiple Slack links in registry" (let [[_output _flushed registry] (process-with-registry " and ")] diff --git a/test/metabase/metabot/agent/memory_test.clj b/test/metabase/metabot/agent/memory_test.clj index add9d0ab46f1..c96b5d7a1a89 100644 --- a/test/metabase/metabot/agent/memory_test.clj +++ b/test/metabase/metabot/agent/memory_test.clj @@ -11,7 +11,6 @@ (is (= messages (:input-messages mem))) (is (= state (:state mem))) (is (= [] (:steps-taken mem))))) - (testing "initializes with default state when nil" (let [messages [{:role :user :content "Hello"}] mem (memory/initialize messages nil)] @@ -26,7 +25,6 @@ mem' (memory/add-step mem parts)] (is (= 1 (count (:steps-taken mem')))) (is (= parts (-> mem' :steps-taken first :parts))))) - (testing "adds multiple steps" (let [mem (memory/initialize [] {}) parts1 [{:type :tool-input :function "search"}] @@ -50,7 +48,6 @@ mem' (memory/update-state mem {:charts {"c1" {:id "c1"}}})] (is (= {:queries {} :charts {"c1" {:id "c1"}}} (memory/get-state mem'))))) - (testing "merges state updates" (let [mem (memory/initialize [] {:queries {"q1" {:id "q1"}}}) mem' (memory/update-state mem {:charts {"c1" {:id "c1"}}})] @@ -82,7 +79,6 @@ (memory/add-step [{:type :text}]) (memory/add-step [{:type :tool-input}]))] (is (= 3 (memory/iteration-count mem))))) - (testing "returns 0 for new memory" (let [mem (memory/initialize [] {})] (is (= 0 (memory/iteration-count mem)))))) diff --git a/test/metabase/metabot/agent/messages_test.clj b/test/metabase/metabot/agent/messages_test.clj index 6bd9c99a0e8d..0995ac1d925d 100644 --- a/test/metabase/metabot/agent/messages_test.clj +++ b/test/metabase/metabot/agent/messages_test.clj @@ -15,7 +15,6 @@ (testing "plain user message" (is (=? [{:role :user :content "Hello"}] (messages/input-message->parts {:role :user :content "Hello"})))) - (testing "user message with string role" (is (=? [{:role :user :content "Hello"}] (messages/input-message->parts {:role "user" :content "Hello"}))))) @@ -24,7 +23,6 @@ (testing "assistant with plain text" (is (=? [{:type :text :text "Hi there"}] (messages/input-message->parts {:role :assistant :content "Hi there"})))) - (testing "assistant with tool_calls (OpenAI style)" (is (=? [{:type :text :text "Searching..."} {:type :tool-input :id "t1" :function "search" :arguments {:q "test"}}] @@ -32,13 +30,11 @@ {:role :assistant :content "Searching..." :tool_calls [{:id "t1" :name "search" :arguments "{\"q\":\"test\"}"}]})))) - (testing "assistant with only tool_calls (no content)" (is (=? [{:type :tool-input :id "t1" :function "search" :arguments {}}] (messages/input-message->parts {:role :assistant :tool_calls [{:id "t1" :name "search" :arguments "{}"}]})))) - (testing "assistant with content blocks (Claude style)" (is (=? [{:type :text :text "Let me check..."} {:type :tool-input :id "t1" :function "search" :arguments {:q "test"}}] @@ -46,13 +42,11 @@ {:role :assistant :content [{:type "text" :text "Let me check..."} {:type "tool_use" :id "t1" :name "search" :input {:q "test"}}]})))) - (testing "assistant with only content block tool_use (no text)" (is (=? [{:type :tool-input :id "t1" :function "search"}] (messages/input-message->parts {:role :assistant :content [{:type "tool_use" :id "t1" :name "search" :input {:q "test"}}]})))) - (testing "malformed tool_call arguments fall through" (is (=? [{:type :tool-input :id "t1" :function "search" :arguments "{bad-json"}] (messages/input-message->parts @@ -64,7 +58,6 @@ (is (=? [{:type :tool-output :id "t1" :result {:output "Found 42"}}] (messages/input-message->parts {:role :tool :tool_call_id "t1" :content "Found 42"})))) - (testing "tool result with string role" (is (=? [{:type :tool-output :id "t1" :result {:output "results"}}] (messages/input-message->parts @@ -89,7 +82,6 @@ (messages/build-message-history {} (memory/initialize [{:role :user :content "Hello"}] {}))))) - (testing "includes assistant text from input" (is (=? [{:role :user :content "Hello"} {:type :text :text "Hi there"}] @@ -97,7 +89,6 @@ {} (memory/initialize [{:role :user :content "Hello"} {:role :assistant :content "Hi there"}] {}))))) - (testing "includes step parts from memory" (is (=? [{:role :user :content #(str/ends-with? % "Hello")} {:type :text :text "Response text"}] @@ -105,7 +96,6 @@ {} (-> (memory/initialize [{:role :user :content "Hello"}] {}) (memory/add-step [{:type :text :text "Response text"}])))))) - (testing "includes tool calls from steps" (is (=? [{:role :user :content #(str/ends-with? % "Search for revenue")} {:type :tool-input :id "t1" :function "search" :arguments {:query "revenue"}}] @@ -116,7 +106,6 @@ :id "t1" :function "search" :arguments {:query "revenue"}}])))))) - (testing "includes tool results from steps" (is (=? [{:role :user :content #(str/ends-with? % "Search")} {:type :tool-input :id "t1" :function "search"} @@ -126,7 +115,6 @@ (-> (memory/initialize [{:role :user :content "Search"}] {}) (memory/add-step [{:type :tool-input :id "t1" :function "search" :arguments {:query "test"}}]) (memory/add-step [{:type :tool-output :id "t1" :result {:data []}}])))))) - (testing "handles multiple iterations" (is (=? [{:role :user :content #(str/ends-with? % "Hello")} {:type :tool-input :id "t1"} @@ -138,7 +126,6 @@ (memory/add-step [{:type :tool-input :id "t1" :function "search" :arguments {}}]) (memory/add-step [{:type :tool-output :id "t1" :result {:data []}}]) (memory/add-step [{:type :text :text "Found results"}])))))) - (testing "filters out non-message parts from steps" (is (=? [{:role :user :content #(str/ends-with? % "Hello")} {:type :text :text "Response"}] @@ -148,7 +135,6 @@ (memory/add-step [{:type :start :messageId "m1"} {:type :text :text "Response"} {:type :usage :usage {:promptTokens 10}}])))))) - (testing "merges consecutive assistant messages from input history" ;; Frontend may send separate text and tool_calls messages (is (=? [{:role :user :content "Hello"} @@ -218,7 +204,6 @@ {:first_day_of_week "Monday"} (memory/initialize [{:role :user :content "Hi"}] {})))] (is (str/includes? content "Monday")))) - (testing "default first_day_of_week is Sunday" (let [content (last-user-content (messages/build-message-history @@ -272,14 +257,12 @@ :model "claude-sonnet-4-5-20250929"}] (is (=? {:role "system" :content #"(?s).*Metabot.*"} (messages/build-system-message {} profile {}))))) - (testing "includes viewing context when provided" (let [context {:user_is_viewing [{:type "dashboard" :id 1 :name "Sales Dashboard"}]} profile {:prompt-template "internal.selmer" :model "claude-sonnet-4-5-20250929"}] (is (=? {:content #(not (str/blank? %))} (messages/build-system-message context profile {}))))) - (testing "handles empty context gracefully" (let [profile {:prompt-template "internal.selmer" :model "claude-sonnet-4-5-20250929"}] diff --git a/test/metabase/metabot/agent/profiles_test.clj b/test/metabase/metabot/agent/profiles_test.clj index bc3f1754ab43..cd017a18d35f 100644 --- a/test/metabase/metabot/agent/profiles_test.clj +++ b/test/metabase/metabot/agent/profiles_test.clj @@ -23,7 +23,6 @@ (is (contains? (tool-names profile) "search")) (is (contains? (tool-names profile) "create_chart")) (is (contains? (tool-names profile) "edit_chart")))) - (testing "retrieves internal profile with default provider" (let [profile (profiles/get-profile :internal)] (is (some? profile)) @@ -32,12 +31,11 @@ (is (= 10 (:max-iterations profile))) (is (= 0.3 (:temperature profile))) (is (vector? (:tools profile))) - ;; Should have more tools than embedding_next profile + ;; Should have more tools than embedding_next profile (is (> (count (:tools profile)) 5)) (is (contains? (tool-names profile) "search")) (is (contains? (tool-names profile) "create_sql_query")) (is (contains? (tool-names profile) "create_chart")))) - (testing "retrieves transforms_codegen profile" (let [profile (profiles/get-profile :transforms_codegen)] (is (some? profile)) @@ -48,7 +46,6 @@ (is (vector? (:tools profile))) (is (contains? (tool-names profile) "search")) (is (contains? (tool-names profile) "list_available_fields")))) - (testing "retrieves sql profile" (let [profile (profiles/get-profile :sql)] (is (=? {:name :sql @@ -59,7 +56,6 @@ profile)) (is (contains? (tool-names profile) "search")) (is (contains? (tool-names profile) "create_sql_query")))) - (testing "retrieves nlq profile" (let [profile (profiles/get-profile :nlq)] (is (some? profile)) @@ -69,7 +65,6 @@ (is (= 0.3 (:temperature profile))) (is (contains? (tool-names profile) "search")) (is (contains? (tool-names profile) "construct_notebook_query")))) - (testing "retrieves slackbot profile" (let [profile (profiles/get-profile :slackbot)] (is (some? profile)) @@ -83,10 +78,8 @@ (is (contains? (tool-names profile) "static_viz")) (is (contains? (tool-names profile) "create_alert")) (is (contains? (tool-names profile) "create_dashboard_subscription")))) - (testing "returns nil for unknown profile" (is (nil? (profiles/get-profile :unknown-profile)))) - (testing "all profiles have required keys" (doseq [profile-id [:embedding_next :internal :transforms_codegen :sql :nlq :slackbot]] (let [profile (profiles/get-profile profile-id)] @@ -139,7 +132,6 @@ "edit_sql_query should NOT be available without permission:write_sql_queries") (is (not (contains? tools "replace_sql_query")) "replace_sql_query should NOT be available without permission:write_sql_queries"))) - (testing "full capabilities including SQL write permission should include SQL tools" (let [full-capabilities ["frontend:navigate_user_v1" "permission:save_questions" @@ -155,7 +147,6 @@ "edit_sql_query should be available with permission:write_sql_queries") (is (contains? tools "replace_sql_query") "replace_sql_query should be available with permission:write_sql_queries"))) - (testing "empty capabilities should exclude all capability-gated tools" (let [tools (profiles/get-tools-for-profile :internal [])] (is (contains? tools "search") "ungated tools should remain") diff --git a/test/metabase/metabot/agent/prompts_test.clj b/test/metabase/metabot/agent/prompts_test.clj index 502414acf0e0..9c2c702dfebe 100644 --- a/test/metabase/metabot/agent/prompts_test.clj +++ b/test/metabase/metabot/agent/prompts_test.clj @@ -11,13 +11,11 @@ (is (string? template)) (is (> (count template) 1000)) (is (re-find #"metabot_name" template)))) - (testing "loads embedding-next.selmer template" (let [template (prompts/load-system-prompt-template "embedding-next.selmer")] (is (some? template)) (is (string? template)) (is (re-find #"metabot_name" template)))) - (testing "returns nil for non-existent template" (let [template (prompts/load-system-prompt-template "non-existent.selmer")] (is (nil? template))))) @@ -28,17 +26,14 @@ (is (some? instructions)) (is (string? instructions)) (is (re-find #"PostgreSQL" instructions)))) - (testing "loads mysql dialect" (let [instructions (prompts/load-dialect-instructions "mysql")] (is (some? instructions)) (is (string? instructions)) (is (re-find #"MySQL" instructions)))) - (testing "returns nil for non-existent dialect" (let [instructions (prompts/load-dialect-instructions "non-existent")] (is (nil? instructions)))) - (testing "returns nil for nil dialect" (let [instructions (prompts/load-dialect-instructions nil)] (is (nil? instructions))))) @@ -49,7 +44,6 @@ context {:name "Metabot" :day "Monday"} rendered (prompts/render-system-prompt template context)] (is (= "Hello Metabot, today is Monday" rendered)))) - (testing "handles missing variables gracefully" (let [template "Hello {{name}}" context {} @@ -58,14 +52,12 @@ ;; Selmer leaves undefined variables as empty or the variable name (is (or (= "Hello " rendered) (= "Hello {{name}}" rendered))))) - (testing "handles conditionals" (let [template "{% if show %}visible{% endif %}" context-true {:show true} context-false {:show false}] (is (= "visible" (prompts/render-system-prompt template context-true))) (is (= "" (prompts/render-system-prompt template context-false))))) - (testing "handles loops" (let [template "{% for item in items %}{{item}} {% endfor %}" context {:items ["a" "b" "c"]} @@ -76,35 +68,27 @@ (testing "caches loaded templates" ;; Clear cache first (prompts/clear-cache!) - ;; Load template - should cache it (let [template1 (prompts/get-cached-system-prompt "internal.selmer")] (is (some? template1)) - ;; Load again - should return from cache (let [template2 (prompts/get-cached-system-prompt "internal.selmer")] (is (= template1 template2))))) - (testing "caches dialect instructions" ;; Clear cache first (prompts/clear-cache!) - ;; Load dialect - should cache it (let [dialect1 (prompts/get-cached-dialect-instructions "postgresql")] (is (some? dialect1)) - ;; Load again - should return from cache (let [dialect2 (prompts/get-cached-dialect-instructions "postgresql")] (is (= dialect1 dialect2))))) - (testing "clear-cache! removes all cached templates" ;; Load some templates (prompts/get-cached-system-prompt "internal.selmer") (prompts/get-cached-dialect-instructions "postgresql") - ;; Clear cache (prompts/clear-cache!) - ;; Cache should be empty (we can't directly test this, but we can reload) (let [template (prompts/get-cached-system-prompt "internal.selmer")] (is (some? template))))) @@ -128,7 +112,6 @@ (is (> (count content) 100)) (is (re-find #"Metabot" content)) (is (re-find #"2024-01-15 14:30:00" content)))) - (testing "includes dialect instructions when dialect specified" (let [profile {:prompt-template "embedding-next.selmer"} context {:current_time "2024-01-15 14:30:00" @@ -139,7 +122,6 @@ ;; Note: The embedding template might not reference dialect instructions, ;; but they should be available in the template context (is (string? content)))) - (testing "falls back to default message if template not found" (let [profile {:prompt-template "non-existent.selmer"} context {} @@ -147,7 +129,6 @@ content (prompts/build-system-message-content profile context tools)] (is (some? content)) (is (= "You are Metabot, a data analysis assistant for Metabase." content)))) - (testing "uses default template name if not specified" (let [profile {} context {:current_time "2024-01-15 14:30:00"} @@ -156,7 +137,6 @@ (is (some? content)) (is (string? content)) (is (> (count content) 1000)))) - (testing "renders transform codegen template with literal model syntax" (let [profile {:prompt-template "transform-codegen.selmer"} context {:current_time "2024-01-15 14:30:00" @@ -171,7 +151,6 @@ (is (str/includes? content "{{snippet: recent orders}}")) (is (not (str/includes? content "{%raw%}"))) (is (not (str/includes? content "{% safe %}"))))) - (testing "current user info is not in system message (moved to message injection)" (let [profile {:prompt-template "internal.selmer"} context {:current_time "2024-01-15 14:30:00" diff --git a/test/metabase/metabot/agent/scope_enforcement_test.clj b/test/metabase/metabot/agent/scope_enforcement_test.clj index 1eb0ffbb6e9d..a036d26a431c 100644 --- a/test/metabase/metabot/agent/scope_enforcement_test.clj +++ b/test/metabase/metabot/agent/scope_enforcement_test.clj @@ -12,22 +12,18 @@ (deftest ^:parallel filter-by-scope-test (let [no-scope (with-meta (fn [_] {:output "legacy"}) {:tool-name "legacy" :schema [:=> [:cat :map] :map]})] - (testing "with unrestricted scope, all tools pass" (binding [scope/*current-user-scope* api-scope/unrestricted] (is (api-scope/scope-matches? scope/*current-user-scope* "agent:sql:create")) (is (api-scope/scope-matches? scope/*current-user-scope* "agent:search")))) - (testing "with empty scope, no scoped tools pass" (binding [scope/*current-user-scope* #{}] (is (not (api-scope/scope-matches? scope/*current-user-scope* "agent:sql:create"))) (is (not (api-scope/scope-matches? scope/*current-user-scope* "agent:search"))))) - (testing "with wildcard scope, matching tools pass" (binding [scope/*current-user-scope* #{"agent:sql:*"}] (is (api-scope/scope-matches? scope/*current-user-scope* "agent:sql:create")) (is (not (api-scope/scope-matches? scope/*current-user-scope* "agent:search"))))) - (testing "tools without scope always pass" (binding [scope/*current-user-scope* #{}] (is (nil? (:scope (meta no-scope)))))))) @@ -45,19 +41,15 @@ memory-atom (atom {}) wrapped (tools/wrap-tools-with-state tools-map memory-atom nil) wrapped-fn (get-in wrapped ["test_tool" :fn])] - (testing "tool executes when scope is satisfied" (binding [scope/*current-user-scope* api-scope/unrestricted] (is (= {:output "success"} (wrapped-fn {}))))) - (testing "tool executes with matching wildcard scope" (binding [scope/*current-user-scope* #{"agent:sql:*"}] (is (= {:output "success"} (wrapped-fn {}))))) - (testing "tool executes with exact scope match" (binding [scope/*current-user-scope* #{"agent:sql:create"}] (is (= {:output "success"} (wrapped-fn {}))))) - (testing "tool returns denial when scope is not satisfied" (binding [scope/*current-user-scope* #{}] (let [result (wrapped-fn {})] @@ -65,7 +57,6 @@ (is (re-find #"do not have permission" (:output result))) ;; scope string should NOT be leaked in the output (is (not (re-find #"agent:sql" (:output result))))))) - (testing "tool returns denial with wrong scope" (binding [scope/*current-user-scope* #{"agent:notebook:*"}] (let [result (wrapped-fn {})] @@ -79,7 +70,6 @@ memory-atom (atom {}) wrapped (tools/wrap-tools-with-state tools-map memory-atom nil) wrapped-fn (get-in wrapped ["legacy_tool" :fn])] - (testing "tool without :scope always executes regardless of current scope" (binding [scope/*current-user-scope* #{}] (is (= {:output "no-scope-tool"} (wrapped-fn {})))) diff --git a/test/metabase/metabot/agent/streaming_test.clj b/test/metabase/metabot/agent/streaming_test.clj index 5d7f0ade91ea..4e7fc9a8cbfa 100644 --- a/test/metabase/metabot/agent/streaming_test.clj +++ b/test/metabase/metabot/agent/streaming_test.clj @@ -11,7 +11,6 @@ (is (= :data (:type part))) (is (= "navigate_to" (:data-type part))) (is (= url (:data part))))) - (testing "works with various URL formats" (let [part1 (streaming/navigate-to-part "/model/123") part2 (streaming/navigate-to-part "/metric/456") @@ -26,7 +25,6 @@ url (streaming/query->question-url query)] (is (str/starts-with? url "/question#")) (is (> (count url) 10)))) - (testing "handles complex queries" (let [query {:database 1 :type :query @@ -44,7 +42,6 @@ (is (= :data (:type (first parts)))) (is (= "navigate_to" (:data-type (first parts)))) (is (= "/question#xyz" (:data (first parts)))))) - (testing "handles multiple reactions" (let [reactions [{:type :metabot.reaction/redirect :url "/model/1"} {:type :metabot.reaction/redirect :url "/metric/2"}] @@ -52,7 +49,6 @@ (is (= 2 (count parts))) (is (= "/model/1" (:data (first parts)))) (is (= "/metric/2" (:data (second parts)))))) - (testing "ignores non-redirect reactions" (let [reactions [{:type :metabot.reaction/message :message "hello"} {:type :metabot.reaction/redirect :url "/model/1"} @@ -60,7 +56,6 @@ parts (streaming/reactions->data-parts reactions)] (is (= 1 (count parts))) (is (= "/model/1" (:data (first parts)))))) - (testing "returns empty vector for empty reactions" (is (= [] (streaming/reactions->data-parts []))) (is (= [] (streaming/reactions->data-parts nil))))) @@ -84,7 +79,6 @@ (is (= "todo_list" (:data-type part))) (is (= 1 (:version part))) (is (= todos (:data part))))) - (testing "handles empty todo list" (let [part (streaming/todo-list-part [])] (is (= :data (:type part))) @@ -101,7 +95,6 @@ (is (= "code_edit" (:data-type part))) (is (= 1 (:version part))) (is (= edit-data (:data part))))) - (testing "handles complex edit data" (let [edit-data {:buffer_id "buf-1" :mode "edit" @@ -121,7 +114,6 @@ (is (= "transform_suggestion" (:data-type part))) (is (= 1 (:version part))) (is (= suggestion (:data part))))) - (testing "handles Python transform suggestion" (let [suggestion {:id 2 :name "Python Transform" @@ -141,7 +133,6 @@ (is (= "adhoc_viz" (:data-type part))) (is (= 1 (:version part))) (is (= value (:data part))))) - (testing "handles minimal value without title/display" (let [value {:query {:database 1} :link "/question#xyz"} part (streaming/adhoc-viz-part value)] @@ -188,19 +179,16 @@ (is (= :tool-output (:type (first result)))) (is (= :data (:type (second result)))) (is (= "navigate_to" (:data-type (second result)))))) - (testing "passes through non-tool-output parts unchanged" (let [parts [{:type :text :text "hello"} {:type :usage :tokens 100}] result (into [] streaming/expand-reactions-xf parts)] (is (= parts result)))) - (testing "handles tool-output without reactions" (let [parts [{:type :tool-output :id "t1" :result {:output "done"}}] result (into [] streaming/expand-reactions-xf parts)] (is (= 1 (count result))) (is (= :tool-output (:type (first result)))))) - (testing "handles multiple reactions from single tool" (let [parts [{:type :tool-output :id "t1" @@ -222,17 +210,14 @@ (is (= 2 (count result))) (is (= :tool-output (:type (first result)))) (is (= todo-part (second result))))) - (testing "passes through non-tool-output parts unchanged" (let [parts [{:type :text :text "hello"}] result (into [] streaming/expand-data-parts-xf parts)] (is (= parts result)))) - (testing "handles tool-output without data-parts" (let [parts [{:type :tool-output :id "t1" :result {:output "done"}}] result (into [] streaming/expand-data-parts-xf parts)] (is (= 1 (count result))))) - (testing "handles multiple data-parts from single tool" (let [parts [{:type :tool-output :id "t1" @@ -250,12 +235,10 @@ result (into [] (streaming/post-process-xf {"q1" query} {} (atom {})) parts)] (is (= 1 (count result))) (is (re-find #"\[Results\]\(/question#" (:text (first result)))))) - (testing "passes through non-text parts unchanged" (let [parts [{:type :tool-input :id "t1" :function "search"}] result (into [] (streaming/post-process-xf {} {} (atom {})) parts)] (is (= parts result)))) - (testing "accumulates queries from tool-output structured-output" (let [query {:database 1 :type :query :query {:source-table 1}} parts [{:type :tool-output @@ -265,7 +248,6 @@ result (into [] (streaming/post-process-xf {} {} (atom {})) parts)] (is (= 2 (count result))) (is (re-find #"\[Results\]\(/question#" (:text (second result)))))) - (testing "flushes incomplete links at end" (let [parts [{:type :text :text "Check [incomplete"}] result (into [] (streaming/post-process-xf {} {} (atom {})) parts)] @@ -273,7 +255,6 @@ (is (<= 1 (count result))) (let [all-text (->> result (filter #(= :text (:type %))) (map :text) (apply str))] (is (= "Check [incomplete" all-text))))) - (testing "resolves model/metric/dashboard links without state" (let [parts [{:type :text :text "[Model](metabase://model/123)"}] result (into [] (streaming/post-process-xf {} {} (atom {})) parts)] @@ -295,11 +276,9 @@ (is (= "navigate_to" (:data-type (nth result 1)))) (is (= "todo_list" (:data-type (nth result 2)))) (is (re-find #"\[Link\]\(/question#" (:text (nth result 3)))))) - (testing "works with empty parts" (let [result (into [] (streaming/post-process-xf {} {} (atom {})) [])] (is (= [] result)))) - (testing "preserves order: tool-output, reactions, data-parts, text" (let [parts [{:type :tool-output :id "t1" diff --git a/test/metabase/metabot/agent/tool_output_test.clj b/test/metabase/metabot/agent/tool_output_test.clj index eda5615e7905..bb822895a8c2 100644 --- a/test/metabase/metabot/agent/tool_output_test.clj +++ b/test/metabase/metabot/agent/tool_output_test.clj @@ -87,12 +87,10 @@ #(resource-tools/read-resource-tool {:uris [(str "metabase://table/" (mt/id :orders))]}) #"xml outputs tag #" 10000 (count output)) "Should be not too big")))) - (let [metric-query (-> (lib/query (mt/metadata-provider) (lib.metadata/table (mt/metadata-provider) (mt/id :orders))) (lib/aggregate (lib/sum (lib.metadata/field (mt/metadata-provider) diff --git a/test/metabase/metabot/agent/user_context_test.clj b/test/metabase/metabot/agent/user_context_test.clj index 14f4fafe79c9..c13838b3efa2 100644 --- a/test/metabase/metabot/agent/user_context_test.clj +++ b/test/metabase/metabot/agent/user_context_test.clj @@ -19,12 +19,10 @@ ;; Should contain date components (is (re-find #"2024" result)) (is (re-find #"14:30" result)))) - (testing "uses current_user_time when provided" (let [context {:current_user_time "2024-02-01T09:15:00"} result (user-context/format-current-time context)] (is (= "2024-02-01T09:15:00" result)))) - (testing "handles missing timezone by using current time" (let [context {} result (user-context/format-current-time context)] @@ -32,7 +30,6 @@ (is (string? result)) ;; Should contain some date (is (re-find #"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}" result)))) - (testing "handles invalid timezone gracefully" (is (string? (user-context/format-current-time {:current_time_with_timezone "invalid"}))))) @@ -43,26 +40,22 @@ :sql_engine "PostgreSQL"}]} result (user-context/extract-sql-dialect context)] (is (= "postgresql" result)))) - (testing "extracts sql_engine from adhoc item with native dataset-query (frontend payload)" (let [context {:user_is_viewing [{:type "adhoc" :query (lib/native-query (mt/metadata-provider) "select 1") :sql_engine "PostgreSQL"}]} result (user-context/extract-sql-dialect context)] (is (= "postgresql" result)))) - (testing "returns nil for adhoc notebook (MBQL) query" (let [context {:user_is_viewing [{:type "adhoc" :query (let [mp (mt/metadata-provider)] (lib/query mp (lib.metadata/table mp (mt/id :venues))))}]} result (user-context/extract-sql-dialect context)] (is (nil? result)))) - (testing "returns nil when no viewing context" (let [context {} result (user-context/extract-sql-dialect context)] (is (nil? result)))) - (testing "returns nil when no sql_engine in context" (let [context {:user_is_viewing [{:type "table" :id 1}]} result (user-context/extract-sql-dialect context)] @@ -77,7 +70,6 @@ (user-context/format-viewing-context {:user_is_viewing [{:type "adhoc" :query (lib/query mp (lib.metadata/table mp (meta/id :venues)))}]})))) - (testing "formats adhoc native SQL query from frontend (type: adhoc, query.type: native)" (let [result (user-context/format-viewing-context {:user_is_viewing [{:type "adhoc" @@ -87,7 +79,6 @@ (is (re-find #"SQL editor" result)) (is (re-find #"SELECT \* FROM orders WHERE total > 100" result)) (is (re-find #"postgres" result)))) - (testing "formats adhoc native SQL query with error" (let [result (user-context/format-viewing-context {:user_is_viewing [{:type "adhoc" @@ -97,7 +88,6 @@ (is (re-find #"SQL editor" result)) (is (re-find #"SELECT \* FROM invalid" result)) (is (re-find #"Table 'invalid' not found" result)))) - (testing "formats transform context" (let [context {:user_is_viewing [{:type "transform" :id 123 @@ -108,7 +98,6 @@ (is (re-find #"Transform" result)) (is (re-find #"Daily Revenue" result)) (is (re-find #"sql" result)))) - (testing "formats transform context with error" (let [context {:user_is_viewing [{:type "transform" :id 123 @@ -119,7 +108,6 @@ (is (some? result)) (is (re-find #"Transform error" result)) (is (re-find #"ERROR: relation \"missing_table\" does not exist" result)))) - (testing "formats code editor context" (let [context {:user_is_viewing [{:type "code_editor" :buffers [{:id "buffer1" @@ -131,7 +119,6 @@ (is (re-find #"code editor" result)) (is (re-find #"python" result)) (is (re-find #"Line 10" result)))) - (testing "formats code editor with selection" (let [context {:user_is_viewing [{:type "code_editor" :buffers [{:id "buffer1" @@ -145,7 +132,6 @@ (is (some? result)) (is (re-find #"Selected lines:" result)) (is (re-find #"SELECT \* FROM foo" result)))) - (testing "formats code editor with no buffers" (let [context {:user_is_viewing [{:type "code_editor" :buffers []}]} @@ -164,7 +150,6 @@ (is (re-find #"table" result)) (is (re-find #"users" result)) (is (re-find #"User accounts" result)))) - (testing "formats model entity" (let [context {:user_is_viewing [{:type "model" :id 456 @@ -174,7 +159,6 @@ (is (some? result)) (is (re-find #"model" result)) (is (re-find #"Revenue Model" result)))) - (testing "formats question entity" (let [context {:user_is_viewing [{:type "question" :id 789 @@ -183,7 +167,6 @@ (is (some? result)) (is (re-find #"question" result)) (is (re-find #"Top Customers" result)))) - (testing "formats metric entity" (let [context {:user_is_viewing [{:type "metric" :id 111 @@ -192,7 +175,6 @@ (is (some? result)) (is (re-find #"metric" result)) (is (re-find #"Total Revenue" result)))) - (testing "formats dashboard entity" (let [context {:user_is_viewing [{:type "dashboard" :id 222 @@ -201,7 +183,6 @@ (is (some? result)) (is (re-find #"dashboard" result)) (is (re-find #"Executive Dashboard" result)))) - (testing "handles keyword types in viewing context" (let [context {:user_is_viewing [{:type :table :id 321 @@ -210,12 +191,10 @@ (is (some? result)) (is (re-find #"table" result)) (is (re-find #"orders" result)))) - (testing "handles empty viewing context" (let [context {} result (user-context/format-viewing-context context)] (is (= "" result)))) - (testing "handles multiple viewing items" (let [context {:user_is_viewing [{:type "table" :id 1 :name "users"} {:type "question" :id 2 :name "Top Users"}]} @@ -239,18 +218,15 @@ (is (re-find #"Revenue Query" result)) (is (re-find #"Sales Dashboard" result)) (is (re-find #"Daily revenue" result)))) - (testing "includes guidance text" (let [context {:user_recently_viewed [{:type "table" :id 1 :name "users"}]} result (user-context/format-recent-views context)] (is (re-find #"might be relevant" result)) (is (re-find #"search tool" result)))) - (testing "returns empty string when no recent views" (let [context {} result (user-context/format-recent-views context)] (is (= "" result)))) - (testing "returns empty string when recent views is an empty vector (e.g. after verified-only filter)" (let [context {:user_recently_viewed []} result (user-context/format-recent-views context)] @@ -268,7 +244,6 @@ :email "jane@example.com" :glossary {"ARR" "Annual Recurring Revenue"}}) (user-context/format-current-user-info {}))))) - (testing "returns nil when there is no current user" (with-redefs [entity-details/get-current-user (fn [_] {:output "current user not found"})] @@ -298,7 +273,6 @@ (is (= "Jane Doe" (:current_user_info result))) (is (string? (:viewing_context result))) (is (string? (:recent_views result)))))) - (testing "enriches context from frontend adhoc native query payload" (let [context {:current_time_with_timezone "2024-01-15T14:30:00-05:00" :first_day_of_week "Monday" @@ -312,12 +286,10 @@ (is (= "postgresql" (:sql_dialect result))) (is (re-find #"SQL editor" (:viewing_context result))) (is (re-find #"SELECT \* FROM users" (:viewing_context result))))) - (testing "uses default first_day_of_week when not provided" (let [context {} result (user-context/enrich-context-for-template context)] (is (= "Sunday" (:first_day_of_week result))))) - (testing "handles minimal context" (let [context {} result (user-context/enrich-context-for-template context)] @@ -355,7 +327,6 @@ (is (re-find #"Average Order Value" result)) (is (re-find #"Segments \(Pre-defined Filter Conditions\)" result)) (is (re-find #"Q4 Orders" result))))) - (testing "model viewing context includes measures and segments when present" (with-redefs [entity-details/get-table-details (fn [{:keys [model-id with-measures? with-segments?]}] @@ -380,7 +351,6 @@ (is (re-find #"Total Revenue" result)) (is (re-find #"Segments" result)) (is (re-find #"Enterprise Accounts" result))))) - (testing "table viewing context omits measures/segments sections when none exist" (with-redefs [entity-details/get-table-details (fn [{:keys [entity-id]}] @@ -412,7 +382,6 @@ {:user_is_viewing [{:type "question" :id card-id}]})] (is (re-find #"Retention Cohorts" result)) (is (re-find #"Shows retention by cohort" result)))))) - (testing "question includes display_type in formatted output" (mt/with-test-user :rasta (mt/with-temp [:model/Card {card-id :id} {:name "Revenue Pie Chart" @@ -425,7 +394,6 @@ (let [result (user-context/format-viewing-context {:user_is_viewing [{:type "question" :id card-id}]})] (is (re-find #"display_type=\"pie\"" result)))))) - (testing "dashboard with only type+id fetches name and description from DB" (mt/with-test-user :rasta (mt/with-temp [:model/Dashboard {dash-id :id} {:name "Executive Dashboard" @@ -434,7 +402,6 @@ {:user_is_viewing [{:type "dashboard" :id dash-id}]})] (is (re-find #"Executive Dashboard" result)) (is (re-find #"Top-level KPIs" result)))))) - (testing "table with only type+id fetches details including fields from DB" (mt/with-test-user :rasta (let [result (user-context/format-viewing-context diff --git a/test/metabase/metabot/api/metabot_test.clj b/test/metabase/metabot/api/metabot_test.clj index d71e7164e257..6a82eae27b59 100644 --- a/test/metabase/metabot/api/metabot_test.clj +++ b/test/metabase/metabot/api/metabot_test.clj @@ -65,21 +65,21 @@ (update :tables generate-prompt) (set/rename-keys {:metrics :metric_questions :tables :table_questions})))] - ;; --------------------------- Generating sample prompts --------------------------- + ;; --------------------------- Generating sample prompts --------------------------- (testing "should generate prompt suggestions for metabot" (with-redefs [metabot.example-question-generator/generate-example-questions prompt-generator] - ;; Trigger prompt generation by calling the regenerate endpoint + ;; Trigger prompt generation by calling the regenerate endpoint (mt/user-http-request :crowberto :post 204 (format "metabot/metabot/%d/prompt-suggestions/regenerate" metabot-id))) (let [added-prompts (t2/select [:model/MetabotPrompt [:card.name :model_name] :prompt] :metabot_id metabot-id {:join [[:report_card :card] [:= :card.id :card_id]] :order-by [:metabot_prompt.id]})] - ;; Verify prompts were added to the database + ;; Verify prompts were added to the database (is (= prompts (-> (group-by :model_name added-prompts) (update-vals #(map :prompt %))))))) - ;; --------------------------- Querying sample prompts --------------------------- + ;; --------------------------- Querying sample prompts --------------------------- (let [expected-prompts (into #{} (mapcat val) prompts) all-prompts (mt/user-http-request :rasta :get 200 (format "metabot/metabot/%d/prompt-suggestions" metabot-id))] @@ -123,13 +123,12 @@ (is (=? {:prompts expected-prompts? :limit limit, :offset nil, :total (:total all-prompts)} sample-prompts)))) - ;; --------------------------- Deleting & regenerating sample prompts --------------------------- + ;; --------------------------- Deleting & regenerating sample prompts --------------------------- (let [all-prompt-ids (into #{} (map :id) (:prompts all-prompts)) current-prompt-ids #(t2/select-pks-set :model/MetabotPrompt :metabot_id metabot-id) selected-prompt (rand-nth (:prompts all-prompts)) url (format "metabot/metabot/%d/prompt-suggestions/%d" metabot-id (:id selected-prompt)) remaining-prompt-ids (disj all-prompt-ids (:id selected-prompt))] - (testing "deleting a specific prompt" (testing "normal users cannot delete" (mt/user-http-request :rasta :delete 403 url) @@ -137,7 +136,6 @@ (testing "admins can delete" (mt/user-http-request :crowberto :delete 204 url) (is (= remaining-prompt-ids (current-prompt-ids))))) - (testing "generating new prompts" (let [url (format "metabot/metabot/%d/prompt-suggestions/regenerate" metabot-id)] (testing "normal users are not allowed" @@ -146,7 +144,6 @@ (testing "admin users are allowed" (with-redefs [metabot.example-question-generator/generate-example-questions prompt-generator] (mt/user-http-request :crowberto :post 204 url))))) - (let [new-prompt-ids (current-prompt-ids)] (is (= (count all-prompt-ids) (count new-prompt-ids))) (is (empty? (set/intersection all-prompt-ids new-prompt-ids))) @@ -165,7 +162,6 @@ (mt/with-temp [:model/Metabot {metabot-id-1 :id} {:name "Alpha Metabot"} :model/Metabot {metabot-id-2 :id} {:name "Beta Metabot"} :model/Metabot {metabot-id-3 :id} {:name "Gamma Metabot"}] - (testing "should return all metabots in alphabetical order by name" (let [{response :items} (mt/user-http-request :crowberto :get 200 "metabot/metabot")] (is (= 3 (count response))) @@ -173,7 +169,6 @@ (mapv :name response))) (is (= [metabot-id-1 metabot-id-2 metabot-id-3] (mapv :id response))))) - (testing "should require superuser permissions" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "metabot/metabot")))))))) @@ -185,7 +180,6 @@ :description "Test Description" :use_verified_content true :collection_id collection-id}] - (testing "should return metabot with all fields" (let [response (mt/user-http-request :crowberto :get 200 (format "metabot/metabot/%d" metabot-id))] @@ -194,12 +188,10 @@ (is (= "Test Description" (:description response))) (is (true? (:use_verified_content response))) (is (= collection-id (:collection_id response))))) - (testing "should require superuser permissions" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "metabot/metabot/%d" metabot-id))))) - (testing "should return 404 for non-existent metabot" (is (= "Not found." (mt/user-http-request :crowberto :get 404 @@ -213,7 +205,6 @@ :model/Metabot {metabot-id :id} {:name "Test Metabot" :use_verified_content false :collection_id collection-id-1}] - (testing "should update use_verified_content field" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (constantly nil)] (let [response (mt/user-http-request :crowberto :put 200 @@ -224,7 +215,6 @@ ;; Verify in database (let [updated-metabot (t2/select-one :model/Metabot :id metabot-id)] (is (true? (:use_verified_content updated-metabot))))))) - (testing "should update collection_id field" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (constantly nil)] (let [response (mt/user-http-request :crowberto :put 200 @@ -235,7 +225,6 @@ ;; Verify in database (let [updated-metabot (t2/select-one :model/Metabot :id metabot-id)] (is (= collection-id-2 (:collection_id updated-metabot))))))) - (testing "should update collection_id to null" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (constantly nil)] (let [response (mt/user-http-request :crowberto :put 200 @@ -245,7 +234,6 @@ ;; Verify in database (let [updated-metabot (t2/select-one :model/Metabot :id metabot-id)] (is (= nil (:collection_id updated-metabot))))))) - (testing "should update all fields simultaneously" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (constantly nil)] (let [response (mt/user-http-request :crowberto :put 200 @@ -254,19 +242,16 @@ :collection_id collection-id-1})] (is (= false (:use_verified_content response))) (is (= collection-id-1 (:collection_id response)))))) - (testing "should require superuser permissions" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (format "metabot/metabot/%d" metabot-id) {:use_verified_content true})))) - (testing "should return 404 for non-existent metabot" (is (= "Not found." (mt/user-http-request :crowberto :put 404 (format "metabot/metabot/%d" Integer/MAX_VALUE) {:use_verified_content true})))) - (testing "should prevent enabling verified content without premium feature" (mt/with-premium-features #{} ; no content-verification (is (= "Content verification is a paid feature not currently available to your instance. Please upgrade to use it. Learn more at metabase.com/upgrade/" @@ -368,9 +353,7 @@ :prompt "old prompt 2" :model :model :card_id card-id-2}] - (let [original-prompt-ids #{prompt-id-1 prompt-id-2}] - (testing "should regenerate prompts when use_verified_content changes" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (fn [metabot-id] @@ -381,14 +364,12 @@ (mt/user-http-request :crowberto :put 200 (format "metabot/metabot/%d" metabot-id) {:use_verified_content true}) - - ;; Verify old prompts were deleted and new ones created + ;; Verify old prompts were deleted and new ones created (let [current-prompts (t2/select :model/MetabotPrompt :metabot_id metabot-id) current-prompt-ids (set (map :id current-prompts))] (is (= 1 (count current-prompts))) (is (empty? (set/intersection original-prompt-ids current-prompt-ids))) (is (= "new prompt after verified change" (:prompt (first current-prompts))))))) - (testing "should regenerate prompts when collection_id changes" (with-redefs [metabot.suggested-prompts/generate-sample-prompts (fn [metabot-id] @@ -399,14 +380,12 @@ (mt/user-http-request :crowberto :put 200 (format "metabot/metabot/%d" metabot-id) {:collection_id collection-id-2}) - - ;; Verify prompts were regenerated again + ;; Verify prompts were regenerated again (let [current-prompts (t2/select :model/MetabotPrompt :metabot_id metabot-id)] (is (= 1 (count current-prompts))) (is (= "new prompt after collection change" (:prompt (first current-prompts))))))) - (testing "should NOT regenerate prompts when no relevant fields change" - ;; First, establish a baseline + ;; First, establish a baseline (t2/delete! :model/MetabotPrompt :metabot_id metabot-id) (t2/insert! :model/MetabotPrompt {:metabot_id metabot-id :prompt "baseline prompt" @@ -414,9 +393,8 @@ :card_id card-id-5}) (let [baseline-prompts (t2/select :model/MetabotPrompt :metabot_id metabot-id) baseline-ids (set (map :id baseline-prompts))] - - ;; Make a PUT request that doesn't change verified content or collection_id - ;; (This would be if we add other fields to update in the future) + ;; Make a PUT request that doesn't change verified content or collection_id + ;; (This would be if we add other fields to update in the future) (with-redefs [metabot.suggested-prompts/generate-sample-prompts (fn [_] (throw (Exception. "Should not be called")))] (mt/user-http-request :crowberto :put 200 @@ -424,7 +402,7 @@ {:use_verified_content true ; Same as current value :collection_id collection-id-2})) ; Same as current value - ;; Verify prompts were NOT changed + ;; Verify prompts were NOT changed (let [current-prompts (t2/select :model/MetabotPrompt :metabot_id metabot-id) current-ids (set (map :id current-prompts))] (is (= baseline-ids current-ids)) @@ -456,7 +434,6 @@ :card_id restricted-card-id}] ;; Revoke default All Users access to restricted collection (perms/revoke-collection-permissions! (perms-group/all-users) restricted-coll-id) - (testing "admin sees all prompts" (let [response (mt/user-http-request :crowberto :get 200 (format "metabot/metabot/%d/prompt-suggestions" metabot-id)) @@ -464,7 +441,6 @@ (is (= 2 (:total response))) (is (contains? prompts "Accessible prompt")) (is (contains? prompts "Restricted prompt")))) - (testing "non-admin user only sees prompts for cards in accessible collections" (let [response (mt/user-http-request :rasta :get 200 (format "metabot/metabot/%d/prompt-suggestions" metabot-id)) diff --git a/test/metabase/metabot/api_test.clj b/test/metabase/metabot/api_test.clj index 9ed7d599ce89..1f9a32914ebf 100644 --- a/test/metabase/metabot/api_test.clj +++ b/test/metabase/metabot/api_test.clj @@ -61,23 +61,23 @@ lines (str/split-lines response) conv (t2/select-one :model/MetabotConversation :id conversation-id) messages (t2/select :model/MetabotMessage :conversation_id conversation-id)] - ;; Native agent emits AI SDK v4 line protocol directly + ;; Native agent emits AI SDK v4 line protocol directly (testing "response contains expected line types" - ;; f:{start}, 0:"text" chunks, 2:{state data}, d:{finish with usage} + ;; f:{start}, 0:"text" chunks, 2:{state data}, d:{finish with usage} (is (=? [#"f:.*" #"0:.*" #"2:.*" #"d:.*"] (m/distinct-by #(subs % 0 2) lines))) - ;; Text chunks reassemble to full message + ;; Text chunks reassemble to full message (let [text-lines (filter #(str/starts-with? % "0:") lines)] (is (= "Hello from native agent!" (apply str (map #(json/decode (subs % 2)) text-lines))))) - ;; Finish line includes usage + ;; Finish line includes usage (is (str/includes? (last lines) "promptTokens"))) (is (=? {:user_id (mt/user->id :rasta)} conv)) - ;; Native agent stores parts in raw format + ;; Native agent stores parts in raw format (is (=? [{:total_tokens 0 :role :user :data [{:role "user" :content (:content question)}]} @@ -714,17 +714,14 @@ {:type :tool-input :id "t1"} ;; second usage is cumulative (subsumes first) {:type :usage :usage {:promptTokens 250 :completionTokens 50} :model "gpt-4"}])))) - (testing "handles multiple models independently" (is (= {"model-a" {:prompt 100 :completion 20} "model-b" {:prompt 200 :completion 40}} (metabot.persistence/extract-usage [{:type :usage :usage {:promptTokens 100 :completionTokens 20} :model "model-a"} {:type :usage :usage {:promptTokens 200 :completionTokens 40} :model "model-b"}])))) - (testing "returns empty map when no usage parts" (is (= {} (metabot.persistence/extract-usage [{:type :text :text "hi"}])))) - (testing "missing model defaults to unknown" (is (= {"unknown" {:prompt 50 :completion 10}} (metabot.persistence/extract-usage @@ -735,13 +732,11 @@ (is (= [{:type :tool, :id 1} {:type :tool, :id 2}] (into [] (metabot.persistence/combine-text-parts-xf) [{:type :tool, :id 1} {:type :tool, :id 2}])))) - (testing "combines consecutive text parts" (is (= [{:type :text, :text "hello world"}] (into [] (metabot.persistence/combine-text-parts-xf) [{:type :text, :text "hello "} {:type :text, :text "world"}])))) - (testing "combines multiple runs" (is (= [{:type :text, :text "ab"} {:type :tool, :id 1} @@ -752,10 +747,8 @@ {:type :tool, :id 1} {:type :text, :text "c"} {:type :text, :text "d"}])))) - (testing "handles empty input" (is (= [] (into [] (metabot.persistence/combine-text-parts-xf) [])))) - (testing "handles single text part" (is (= [{:type :text, :text "solo"}] (into [] (metabot.persistence/combine-text-parts-xf) @@ -792,7 +785,6 @@ (is (= {:claude-sonnet-4-6 {:prompt 100 :completion 50}} (:usage msg)) "usage keys should be bare model names, not metabase/anthropic/..."))) - (testing "BYOK provider (no metabase/ prefix) sets ai_proxied false" (let [msg (start-and-finalize-with-provider! "anthropic/claude-sonnet-4-6")] (is (false? (:ai_proxied msg))) diff --git a/test/metabase/metabot/config_test.clj b/test/metabase/metabot/config_test.clj index 37ae355322e0..10e07129bbbf 100644 --- a/test/metabase/metabot/config_test.clj +++ b/test/metabase/metabot/config_test.clj @@ -11,12 +11,10 @@ (testing "explicit metabot-id takes precedence" (is (= "explicit-id" (metabot.config/resolve-dynamic-metabot-id "explicit-id")))) - (testing "falls back to environment variable" (mt/with-temporary-setting-values [metabot.settings/metabot-id "env-metabot-id"] (is (= "env-metabot-id" (metabot.config/resolve-dynamic-metabot-id nil))))) - (testing "falls back to internal default when no explicit or env value" (is (= metabot.config/internal-metabot-id (metabot.config/resolve-dynamic-metabot-id nil))))))) @@ -26,17 +24,14 @@ (testing "explicit profile-id takes highest precedence" (is (= "explicit-profile" (metabot.config/resolve-dynamic-profile-id "explicit-profile" "any-metabot-id")))) - (testing "metabot-id mapping takes second precedence" (is (= "internal" (metabot.config/resolve-dynamic-profile-id nil metabot.config/internal-metabot-id))) (is (= "embedding_next" (metabot.config/resolve-dynamic-profile-id nil metabot.config/embedded-metabot-id)))) - (testing "falls back to default when no matches" (is (= "embedding_next" (metabot.config/resolve-dynamic-profile-id nil "unknown-metabot-id")))) - (testing "single arity version uses dynamic metabot resolution" (mt/with-temporary-setting-values [metabot.settings/metabot-id metabot.config/embedded-metabot-id] (is (= "embedding_next" @@ -73,14 +68,12 @@ profile-id (metabot.config/resolve-dynamic-profile-id "custom-profile" metabot-id)] (is (= "custom-metabot" metabot-id)) (is (= "custom-profile" profile-id))))) - (testing "env metabot-id resolves profile via metabot-id mapping" (mt/with-temporary-setting-values [metabot.settings/metabot-id metabot.config/embedded-metabot-id] (let [metabot-id (metabot.config/resolve-dynamic-metabot-id nil) profile-id (metabot.config/resolve-dynamic-profile-id nil metabot-id)] (is (= metabot.config/embedded-metabot-id metabot-id)) (is (= "embedding_next" profile-id))))) - (testing "defaults work together" (mt/with-temporary-setting-values [metabot.settings/metabot-id nil] (let [metabot-id (metabot.config/resolve-dynamic-metabot-id nil) diff --git a/test/metabase/metabot/context_test.clj b/test/metabase/metabot/context_test.clj index 3da1c76ab8c2..bf86e410c7fb 100644 --- a/test/metabase/metabot/context_test.clj +++ b/test/metabase/metabot/context_test.clj @@ -273,7 +273,6 @@ [:model/Card um] [:model/Dashboard ud]]] (recent-views/update-users-recent-views! (mt/user->id :rasta) model id :view)) - ;; IDs are unique per model but can collide across models (e.g. a Card and a Dashboard ;; can share the same id). Identify items by [:type :id] pairs so cross-model collisions ;; don't mask filtering bugs. @@ -286,7 +285,6 @@ ud* (as-pair "dashboard" ud) table* (as-pair "table" table-id) keys-of (fn [items] (set (map (juxt :type :id) items)))] - (testing "no metabot-id passed -> no filtering (even with :content-verification active)" (mt/with-premium-features #{:content-verification} (let [items (-> (context/create-context {}) :user_recently_viewed) @@ -296,7 +294,6 @@ (is (contains? ks um*)) (is (contains? ks ud*)) (is (contains? ks table*))))) - (testing "use_verified_content=true with :content-verification feature -> filters" (mt/with-premium-features #{:content-verification} (let [items (-> (context/create-context {} {:metabot-id metabot-eid}) @@ -312,7 +309,6 @@ "Should keep 5 items even though 3 unverified items were ahead of older verified items") (is (contains? ks vm2*)) (is (contains? ks vm1*)))))) - (testing "use_verified_content=true but premium feature absent -> no filtering" (mt/with-premium-features #{} (let [items (-> (context/create-context {} {:metabot-id metabot-eid}) @@ -321,7 +317,6 @@ (is (contains? ks uq*)) (is (contains? ks um*)) (is (contains? ks ud*))))) - (testing "metabot-id that does not resolve -> no filtering" (mt/with-premium-features #{:content-verification} (let [items (-> (context/create-context {} {:metabot-id "nonexistent-entity-id"}) diff --git a/test/metabase/metabot/middleware/auth_test.clj b/test/metabase/metabot/middleware/auth_test.clj index 6f3b933577a7..3e3620560416 100644 --- a/test/metabase/metabot/middleware/auth_test.clj +++ b/test/metabase/metabot/middleware/auth_test.clj @@ -49,7 +49,6 @@ signature (compute-slack-signature body timestamp test-signing-secret) result (wrapped-slack-handler (slack-request body timestamp signature))] (is (true? (:slack/validated? result)))))) - (testing "Invalid signature" (mt/with-temporary-raw-setting-values [metabot-slack-signing-secret test-signing-secret] (let [body "test-body" @@ -57,14 +56,12 @@ signature "v0=invalid-signature" result (wrapped-slack-handler (slack-request body timestamp signature))] (is (false? (:slack/validated? result)))))) - (testing "No signature header present - request passes through unchanged" (let [body "test-body" request (-> (ring.mock/request :post "/anyurl") (assoc :body (java.io.ByteArrayInputStream. (.getBytes ^String body "UTF-8")))) result (wrapped-slack-handler request)] (is (not (contains? result :slack/validated?))))) - (testing "No signing secret configured" (mt/with-temporary-raw-setting-values [metabot-slack-signing-secret nil] (let [body "test-body" diff --git a/test/metabase/metabot/models/metabot_test.clj b/test/metabase/metabot/models/metabot_test.clj index ab9369baf2ea..1292167fb3ab 100644 --- a/test/metabase/metabot/models/metabot_test.clj +++ b/test/metabase/metabot/models/metabot_test.clj @@ -27,7 +27,6 @@ :model/Metabot {metabot2-id :id} {:name "Test Metabot 2"} :model/MetabotPrompt _ {:metabot_id metabot1-id :prompt "Prompt 1" :model :metric :card_id card1-id} :model/MetabotPrompt _ {:metabot_id metabot1-id :prompt "Prompt 2" :model :model :card_id card2-id}] - (let [hydrated-metabots (t2/hydrate (t2/select :model/Metabot :id [:in [metabot1-id metabot2-id]]) :prompts)] (testing "should hydrate prompts for metabots with prompts" (let [metabot1 (first (filter #(= (:id %) metabot1-id) hydrated-metabots))] @@ -36,7 +35,6 @@ (set (map :prompt (:prompts metabot1))))) (is (= #{:metric :model} (set (map :model (:prompts metabot1))))))) - (testing "should return empty list for metabots without prompts" (let [metabot2 (first (filter #(= (:id %) metabot2-id) hydrated-metabots))] (is (= [] (:prompts metabot2)))))))))) diff --git a/test/metabase/metabot/native_generation_integration_test.clj b/test/metabase/metabot/native_generation_integration_test.clj index 6e000461bb18..ba1c79d7d577 100644 --- a/test/metabase/metabot/native_generation_integration_test.clj +++ b/test/metabase/metabot/native_generation_integration_test.clj @@ -42,16 +42,13 @@ :model/Card _ (assoc model-data :name "NativeModel1" :collection_id coll-id) :model/Card _ (assoc metric-data :name "NativeMetric1" :collection_id child-id) :model/Metabot {metabot-id :id} {:name "native-test-bot" :collection_id coll-id}] - (let [prompts-by-name {"NativeModel1" ["native q1" "native q2" "native q3" "native q4" "native q5"] "NativeMetric1" ["native m1" "native m2" "native m3" "native m4" "native m5"]} native-mock (make-native-prompt-generator prompts-by-name)] - (testing "regenerate endpoint works with native path" (with-redefs [native-generator/generate-example-questions native-mock] (mt/user-http-request :crowberto :post 204 (format "metabot/metabot/%d/prompt-suggestions/regenerate" metabot-id))) - (let [prompts (t2/select [:model/MetabotPrompt :prompt :model [:card.name :model_name]] :metabot_id metabot-id {:join [[:report_card :card] [:= :card.id :card_id]] @@ -61,7 +58,6 @@ (set (map :prompt prompts)))) (is (= #{:model :metric} (set (map :model prompts)))))) - (testing "native path prompts are replaced on re-regenerate" (let [old-ids (t2/select-pks-set :model/MetabotPrompt :metabot_id metabot-id)] (with-redefs [native-generator/generate-example-questions native-mock] diff --git a/test/metabase/metabot/persistence_test.clj b/test/metabase/metabot/persistence_test.clj index c7c616821e93..eba5c46d6321 100644 --- a/test/metabase/metabot/persistence_test.clj +++ b/test/metabase/metabot/persistence_test.clj @@ -20,14 +20,12 @@ (is (= {:role "user" :type "text" :message "hello"} (select-keys (first result) [:role :type :message]))) (is (string? (:id (first result)))))) - (testing "assistant standard text block preserves block id" (let [result (metabot-persistence/message->chat-messages {:role :assistant :data [{:type "text" :text "hi there" :id "block-1"}]})] (is (= [{:id "block-1" :role "agent" :type "text" :message "hi there"}] (mapv #(select-keys % [:id :role :type :message]) result))))) - (testing "assistant standard text block without id gets a generated one" (let [result (metabot-persistence/message->chat-messages {:role :assistant @@ -35,7 +33,6 @@ (is (= 1 (count result))) (is (string? (:id (first result)))) (is (= "no id" (:message (first result)))))) - (testing "assistant slack-format text block" (let [result (metabot-persistence/message->chat-messages {:role :assistant @@ -43,7 +40,6 @@ (is (= 1 (count result))) (is (= {:role "agent" :type "text" :message "from slack"} (select-keys (first result) [:role :type :message]))))) - (testing "tool-input merged with matching tool-output" (let [result (metabot-persistence/message->chat-messages {:role :assistant @@ -60,7 +56,6 @@ (select-keys (first result) [:id :role :type :name :status :is_error]))) (is (= {:query "foo"} (json/decode+kw (:args (first result))))) (is (= {:rows [1 2 3]} (json/decode+kw (:result (first result))))))) - (testing "tool-output flagged as error" (let [result (metabot-persistence/message->chat-messages {:role :assistant @@ -68,7 +63,6 @@ {:type "tool-output" :id "call-2" :error "exploded"}]})] (is (true? (:is_error (first result)))) (is (nil? (:result (first result)))))) - (testing "tool-input without matching output is left as-is" (let [result (metabot-persistence/message->chat-messages {:role :assistant @@ -77,14 +71,12 @@ (is (= "tool_call" (:type (first result)))) (is (not (contains? (first result) :result))) (is (not (contains? (first result) :is_error))))) - (testing "unknown block types are dropped" (is (= [] (metabot-persistence/message->chat-messages {:role :assistant :data [{:type "data-foo" :payload {}} {:type "mystery"}]})))) - (testing "data parts are converted to data_part chat messages" (let [blocks [{:type "data" :data-type "navigate_to" :data "/question/1"} {:type "data" :data-type "todo_list" :version 1 :data [{:id "t1"}]} @@ -93,7 +85,6 @@ {:role "agent" :type "data_part" :part {:type "todo_list" :version 1 :value [{:id "t1"}]}} {:role "agent" :type "data_part" :part {:type "code_edit" :version 1 :value {:buffer_id "b" :value "v"}}}] (metabot-persistence/message->chat-messages {:role :assistant :data blocks}))))) - (testing "nil :data yields no messages" (is (= [] (metabot-persistence/message->chat-messages {:role :user :data nil}))))) @@ -570,11 +561,9 @@ (testing "default: finished true, no error" (let [[row] (start-and-finalize!)] (is (=? {:finished true :error nil} row)))) - (testing "aborted: finished false flows through, no error" (let [[row] (start-and-finalize! :finished? false)] (is (=? {:finished false :error nil} row)))) - (testing "errored map: JSON-encoded into column, decoded onto chat msg; partial parts persisted" (let [error-data {:message "agent loop API error: 503" :type "java.lang.RuntimeException" @@ -583,7 +572,6 @@ (is (=? {:finished true :error string? :data seq} row)) (is (= error-data (json/decode+kw (:error row)))) (is (= error-data (:error chat-msg))))) - (testing "errored string: any JSON-serializable value accepted" (let [[row chat-msg] (start-and-finalize! :error "boom")] (is (= "\"boom\"" (:error row))) diff --git a/test/metabase/metabot/scope_test.clj b/test/metabase/metabot/scope_test.clj index 489a9644ebc1..413d494b3ed1 100644 --- a/test/metabase/metabot/scope_test.clj +++ b/test/metabase/metabot/scope_test.clj @@ -9,30 +9,24 @@ (testing "exact match" (is (api-scope/scope-matches? #{"agent:sql:create"} "agent:sql:create")) (is (not (api-scope/scope-matches? #{"agent:sql:edit"} "agent:sql:create")))) - (testing "unrestricted wildcard" (is (api-scope/scope-matches? #{"*"} "agent:sql:create")) (is (api-scope/scope-matches? api-scope/unrestricted "agent:anything:here"))) - (testing "hierarchical wildcard" (is (api-scope/scope-matches? #{"agent:sql:*"} "agent:sql:create")) (is (api-scope/scope-matches? #{"agent:sql:*"} "agent:sql:edit")) (is (not (api-scope/scope-matches? #{"agent:sql:*"} "agent:notebook:create")))) - (testing "mid-level wildcard" (is (api-scope/scope-matches? #{"agent:*"} "agent:sql:create")) (is (api-scope/scope-matches? #{"agent:*"} "agent:viz:edit")) (is (not (api-scope/scope-matches? #{"other:*"} "agent:sql:create")))) - (testing "empty scope set grants nothing" (is (not (api-scope/scope-matches? #{} "agent:sql:create"))) (is (not (api-scope/scope-matches? #{} "agent:search")))) - (testing "multiple granted scopes" (is (api-scope/scope-matches? #{"agent:sql:create" "agent:viz:edit"} "agent:sql:create")) (is (api-scope/scope-matches? #{"agent:sql:create" "agent:viz:edit"} "agent:viz:edit")) (is (not (api-scope/scope-matches? #{"agent:sql:create" "agent:viz:edit"} "agent:notebook:create")))) - (testing "malformed scope strings do not match" (is (not (api-scope/scope-matches? #{""} "agent:sql:create"))) (is (not (api-scope/scope-matches? #{":"} "agent:sql:create"))) diff --git a/test/metabase/metabot/self/claude_test.clj b/test/metabase/metabot/self/claude_test.clj index 4d877d5f0f4d..717ccb99c536 100644 --- a/test/metabase/metabot/self/claude_test.clj +++ b/test/metabase/metabot/self/claude_test.clj @@ -126,7 +126,6 @@ :cacheCreationTokens 250 :cacheReadTokens 4200}} usage)))) - (testing "missing cache fields default to 0" (let [events [{:type "message_start" :message {:id "msg-2" @@ -242,7 +241,6 @@ :headers {"x-api-key" "sk-ant-byok"} :body string?} (claude/claude-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Uses ai proxy when explicitly requested" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil) self.core/sse-reducible identity @@ -254,14 +252,12 @@ :body string?} (claude/claude-raw {:input [{:role :user :content "hi"}] :ai-proxy? true}))))) - (testing "Does not fall back to ai proxy when BYOK is missing" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil)] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"No Anthropic API key is set" (claude/claude-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Throws an error if nothing is defined" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil)] (mt/with-temporary-setting-values [llm.settings/llm-proxy-base-url nil] @@ -291,11 +287,9 @@ (is (= 2 (count (:tools body)))) (is (not (contains? t1 :cache_control))) (is (= {:type "ephemeral"} (:cache_control t2))))) - (testing "no :tools key in request when no tools passed" (let [body (capture-claude-request-body! {:input input})] (is (not (contains? body :tools))))) - (testing "no cache_control on structured-output path (schema set)" (let [body (capture-claude-request-body! {:input input @@ -315,7 +309,6 @@ {:input input :system "You are a helpful assistant." :tools [(metabot.tu/get-time-tool)]}))))) - (testing "top-level cache_control is set on the structured-output path too" (is (= {:type "ephemeral"} (:cache_control (capture-claude-request-body! @@ -333,7 +326,6 @@ :text "You are a helpful assistant." :cache_control {:type "ephemeral"}}] (:system body))))) - (testing "system prompt with sentinel is split into cached prefix + uncached suffix" (let [body (capture-claude-request-body! {:input input @@ -344,7 +336,6 @@ {:type "text" :text "Dynamic suffix content."}] (:system body))))) - (testing "no :system key when system is not provided" (let [body (capture-claude-request-body! {:input input})] (is (not (contains? body :system)))))))) @@ -384,7 +375,6 @@ {:body "{\"data\":[]}"})] (is (= {:models []} (claude/list-models {}))))) - (testing "Uses ai proxy when explicitly requested" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil) http/request (fn [req] @@ -396,14 +386,12 @@ {:body "{\"data\":[]}"})] (is (= {:models []} (claude/list-models {:ai-proxy? true}))))) - (testing "Does not fall back to ai proxy when BYOK is missing" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil)] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"No Anthropic API key is set" (claude/list-models {}))))) - (testing "Throws an error if nothing is defined" (with-redefs [llm.settings/llm-anthropic-api-key (constantly nil)] (mt/with-temporary-setting-values [llm.settings/llm-proxy-base-url nil] diff --git a/test/metabase/metabot/self/openai_test.clj b/test/metabase/metabot/self/openai_test.clj index cd9b12337374..1f5f8d729861 100644 --- a/test/metabase/metabot/self/openai_test.clj +++ b/test/metabase/metabot/self/openai_test.clj @@ -179,7 +179,6 @@ :headers {"Authorization" "Bearer sk-ant-byok"} :body string?} (openai/openai-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Uses ai proxy when explicitly requested" (mt/with-temporary-setting-values [llm.settings/llm-openai-api-key nil] (with-redefs [self.core/sse-reducible identity @@ -191,14 +190,12 @@ :body string?} (openai/openai-raw {:input [{:role :user :content "hi"}] :ai-proxy? true})))))) - (testing "Does not fall back to ai proxy when BYOK is missing" (mt/with-temporary-setting-values [llm.settings/llm-openai-api-key nil] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"No OpenAI API key is set" (openai/openai-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Throws an error if nothing is defined" (mt/with-temporary-setting-values [llm.settings/llm-openai-api-key nil llm.settings/llm-proxy-base-url nil] diff --git a/test/metabase/metabot/self/openrouter_test.clj b/test/metabase/metabot/self/openrouter_test.clj index 59b16e0dbdce..3b8ed8f941d8 100644 --- a/test/metabase/metabot/self/openrouter_test.clj +++ b/test/metabase/metabot/self/openrouter_test.clj @@ -199,7 +199,6 @@ :headers {"Authorization" "Bearer sk-or-v1-byok"} :body string?} (openrouter/openrouter-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Uses ai proxy when explicitly requested" (mt/with-temporary-setting-values [llm.settings/llm-openrouter-api-key nil] (with-redefs [self.core/sse-reducible identity @@ -211,14 +210,12 @@ :body string?} (openrouter/openrouter-raw {:input [{:role :user :content "hi"}] :ai-proxy? true})))))) - (testing "Does not fall back to ai proxy when BYOK is missing" (mt/with-temporary-setting-values [llm.settings/llm-openrouter-api-key nil] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"No OpenRouter API key is set" (openrouter/openrouter-raw {:input [{:role :user :content "hi"}]}))))) - (testing "Throws an error if nothing is defined" (mt/with-temporary-setting-values [llm.settings/llm-openrouter-api-key nil llm.settings/llm-proxy-base-url nil] diff --git a/test/metabase/metabot/self_test.clj b/test/metabase/metabot/self_test.clj index 8fe1e79ea652..d4d342739f4d 100644 --- a/test/metabase/metabot/self_test.clj +++ b/test/metabase/metabot/self_test.clj @@ -12,6 +12,7 @@ [metabase.metabot.test-util :as test-util] [metabase.test :as mt] [metabase.util.json :as json] + [metabase.util.log.capture :as log.capture] [ring.adapter.jetty :as jetty])) (set! *warn-on-reflection* true) @@ -53,10 +54,11 @@ (testing "passes required tool choice to LLM providers" (let [captured (atom nil)] (mt/with-premium-features #{:metabase-ai-managed} + ;; `:api-error true` makes `rethrow-api-error!` rethrow as-is, so `::skip` survives on the outer ex-data. (mt/with-dynamic-fn-redefs [http/request (fn [opts] (when (:body opts) (reset! captured (json/decode+kw (:body opts)))) - (throw (ex-info "stop" {::skip true :status 401 :body "skip parsing"})))] + (throw (ex-info "stop" {::skip true :api-error true})))] (mt/with-temporary-setting-values [llm-anthropic-api-key "sk-ant-test-key" llm-proxy-base-url "http://proxy.example" llm-openrouter-api-key "sk-or-v1-test-key" @@ -156,7 +158,6 @@ {:type :text :id "text-1" :text "!"} {:type :usage :usage {:promptTokens 10 :completionTokens 5}}] (into [] (self.core/lite-aisdk-xf) chunks))))) - (testing "still collects tool inputs for JSON parsing" (let [chunks [{:type :start :messageId "msg-1"} {:type :tool-input-start :toolCallId "call-1" :toolName "search"} @@ -166,7 +167,6 @@ (is (= [{:type :start :id "msg-1"} {:type :tool-input :id "call-1" :function "search" :arguments {:query "test"}}] (into [] (self.core/lite-aisdk-xf) chunks))))) - (testing "converts tool-output-available to tool-output" (let [chunks [{:type :tool-output-available :toolCallId "call-1" @@ -191,7 +191,6 @@ result (into [] (self.core/tool-executor-xf test-util/TOOLS) chunks)] (is (= chunks result) "Non-tool chunks should pass through unchanged"))) - (testing "tool-executor-xf executes tool calls and appends results" (let [chunks (test-util/parts->aisdk-chunks [{:type :start :id "msg-123"} @@ -208,7 +207,6 @@ :toolName "get-time" :result string?} tool-result))))) - (testing "tool-executor-xf handles multiple concurrent tool calls" (let [chunks (test-util/parts->aisdk-chunks [{:type :start :id "msg-456"} @@ -222,7 +220,6 @@ "Last two chunks should be tool outputs") (is (= #{"call-1" "call-2"} (set (map :toolCallId tool-results))))))) - (testing "tool-executor-xf handles tools returning reducibles" (let [llm-id "wut-1" input "Little bits and pieces" @@ -239,7 +236,6 @@ :id llm-id :text input} (last (into [] (self.core/aisdk-xf) result)))))) - (testing "tool-executor-xf handles tool execution errors gracefully" (let [chunks (test-util/parts->aisdk-chunks [{:type :start :id "msg-789"} @@ -252,7 +248,6 @@ :error {:message string? :type string?}} (last result))))) - (testing "tool-executor-xf handles nil arguments for no-arg tools" (let [chunks (test-util/parts->aisdk-chunks [{:type :start :id "msg-nil"} @@ -263,7 +258,6 @@ :toolName "no-arg" :result {:output "ok"}} (last result))))) - (testing "tool-executor-xf ignores unknown tool names" (let [chunks (test-util/parts->aisdk-chunks [{:type :start :id "msg-789"} @@ -449,7 +443,6 @@ (is (= "call-123" (:toolCallId parsed))) ;; result should be JSON string (is (string? (:result parsed)))))) - (testing "formats tool error" (let [line (self.core/format-tool-result-line {:id "call-456" :error {:message "Tool failed"}})] @@ -457,7 +450,6 @@ (let [parsed (json/decode+kw (subs line 2))] (is (= "call-456" (:toolCallId parsed))) (is (string? (:error parsed)))))) - (testing ":duration-ms is ignored" (let [line (self.core/format-tool-result-line {:id "call-789" :result {:data [{:id 1}]} @@ -512,7 +504,6 @@ lines)) (is (=? {:usage {:promptTokens 10 :completionTokens 5}} (-> (last lines) (subs 2) (json/decode+kw)))))) - (testing ":external-id overrides the messageId on the start line" (let [parts [{:type :start :id "provider-id" :messageId "provider-msg-id"} {:type :text :text "hi"}] @@ -615,11 +606,9 @@ (is (== 0 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels))))) - ;; mt/with-prometheus-system! is slow, so clear! metrics between tests rather than creating a fresh system (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-retries on transient failures, no errors on eventual success" (let [calls (atom 0)] (mt/with-log-level [metabase.metabot.self :fatal] @@ -636,11 +625,9 @@ (is (== 0 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels)))))) - (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-retries) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-errors on non-retryable failure, no retries" (with-redefs [openrouter/openrouter (fn [_opts] @@ -653,11 +640,9 @@ (is (== 1 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels))))) - (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-errors) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-errors with :error-type llm-sse-error on inline SSE errors" (with-redefs [openrouter/openrouter (constantly (test-util/mock-llm-response [{:type :error :errorText "content policy violation"}]))] @@ -665,7 +650,6 @@ (is (== 1 (mt/metric-value system :metabase-metabot/llm-requests labels))) (is (== 1 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "llm-sse-error"))))) - (testing "reports token usage metrics on :usage parts" (with-redefs [openrouter/openrouter (constantly (test-util/mock-llm-response @@ -678,12 +662,10 @@ (is (== 100 (mt/metric-value system :metabase-metabot/llm-input-tokens labels))) (is (== 25 (mt/metric-value system :metabase-metabot/llm-output-tokens labels))) (is (== 125 (:sum (mt/metric-value system :metabase-metabot/llm-tokens-per-call labels))))) - (analytics/clear! :metabase-metabot/llm-input-tokens) (analytics/clear! :metabase-metabot/llm-output-tokens) (analytics/clear! :metabase-metabot/llm-cache-creation-tokens) (analytics/clear! :metabase-metabot/llm-cache-read-tokens) - (testing "increments cache token counters when the :usage part carries cache fields" ;; :promptTokens is the pre-summed total input (40 fresh + 300 cache_creation + 1200 cache_read = 1540). (with-redefs [openrouter/openrouter @@ -698,12 +680,10 @@ (run! identity (self/call-llm "openrouter/test-model" nil [] {} {:tag "metabot_agent"}))) (is (== 300 (mt/metric-value system :metabase-metabot/llm-cache-creation-tokens labels))) (is (== 1200 (mt/metric-value system :metabase-metabot/llm-cache-read-tokens labels)))) - (analytics/clear! :metabase-metabot/llm-input-tokens) (analytics/clear! :metabase-metabot/llm-output-tokens) (analytics/clear! :metabase-metabot/llm-cache-creation-tokens) (analytics/clear! :metabase-metabot/llm-cache-read-tokens) - (testing "does not increment cache counters when cache fields are absent or zero" (with-redefs [openrouter/openrouter (constantly (test-util/mock-llm-response @@ -736,10 +716,8 @@ (is (== 0 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels))))) - (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-retries on transient failures, no errors on eventual success" (let [calls (atom 0)] (mt/with-log-level [metabase.metabot.self :fatal] @@ -754,11 +732,9 @@ (is (== 0 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels))))) - (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-retries) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-errors on non-retryable failure, no retries" (with-redefs [openrouter/openrouter (fn [_opts] (throw (ex-info "unauthorized" {:status 401})))] @@ -768,11 +744,9 @@ (is (== 1 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "ExceptionInfo")))) (is (pos? (:sum (mt/metric-value system :metabase-metabot/llm-duration-ms labels))))) - (analytics/clear! :metabase-metabot/llm-requests) (analytics/clear! :metabase-metabot/llm-errors) (analytics/clear! :metabase-metabot/llm-duration-ms) - (testing "increments llm-errors with :error-type llm-sse-error on inline SSE errors" (with-redefs [openrouter/openrouter (constantly (test-util/mock-llm-response @@ -781,7 +755,6 @@ (is (== 1 (mt/metric-value system :metabase-metabot/llm-requests labels))) (is (== 1 (mt/metric-value system :metabase-metabot/llm-errors (assoc labels :error-type "llm-sse-error"))))) - (testing "reports token usage metrics on :usage parts" (with-redefs [openrouter/openrouter (constantly (test-util/mock-llm-response @@ -878,3 +851,272 @@ "tag" "test-tag" "session_id" "00000000-0000-0000-0000-000000000002"}}] token-events))))))))) + +(deftest ^:parallel body-preview-test + (let [body-preview #'self.core/body-preview] + (testing "nil, blank, and non-string scalars → nil" + (is (every? nil? (map body-preview [nil "" " " 500 :error true])))) + (testing "plain strings pass through trimmed" + (is (= "Internal Server Error" (body-preview " Internal Server Error ")))) + (testing "JSON envelopes prefer [:error :message] over :error/:detail/:message" + (is (= "model decommissioned" (body-preview {:error {:message "model decommissioned" :type "x"}}))) + (is (= "invalid metric" (body-preview {:error "invalid metric"}))) + (is (= "missing prompt" (body-preview {:detail "missing prompt"}))) + (is (= "bad request" (body-preview {:message "bad request"})))) + (testing "extract-error-message returns nil for non-string values at the recognised keys" + (let [extract #'self.core/extract-error-message] + (is (every? nil? (map extract [{:error {:code 42 :type "x"}} + {:detail [{:loc ["body" "prompt"]}]} + {:message {:code "missing"}} + {:error {:message {:code 500}}} + {:error 42}]))))) + (testing "a non-string, blank, or whitespace-only at one key falls through to a later key" + (is (= "real error" (body-preview {:error {:message {:code 500}} :detail "real error"}))) + (is (= "real error" (body-preview {:error "" :detail "real error"}))) + (is (= "real error" (body-preview {:error " " :detail "real error"})))) + (testing "empty maps and arrays return nil (nothing to preview, no warn)" + (let [msgs (log.capture/with-log-messages-for-level [msgs [metabase.metabot.self.core :warn]] + (is (every? nil? (map body-preview [{} []]))) + (msgs))] + (is (empty? msgs)))) + (testing "non-empty maps/arrays without a recognised error field pr-str into the preview + warn" + (let [bodies [{:request-id "abc" :trace ["frame1"]} + [42 :kw] + [{:request-id "abc"}] + {:error {:code 42 :type "x"}}] + msgs (log.capture/with-log-messages-for-level [msgs [metabase.metabot.self.core :warn]] + (doseq [b bodies] + (is (= (pr-str b) (body-preview b)) + (str "pr-str fallback for " (pr-str b)))) + (msgs))] + (is (= (count bodies) (count msgs)) + "one warn line per pr-str fallback") + (is (every? #(re-find #"unrecognised error body shape" (:message %)) msgs)))) + (testing "JSON arrays probe their first element" + (is (= "rate limited" (body-preview [{:error {:message "rate limited"}} {:type "x"}]))) + (is (= "first message" (body-preview ["first message" "ignored"])))) + (testing "long bodies are truncated to 500 chars with an ellipsis" + (let [preview (body-preview (apply str (repeat 2000 \x)))] + (is (str/ends-with? preview "…")) + (is (= 501 (count preview))))))) + +(defn- caught + "Run `thunk` and return the thrown exception, or nil if it didn't throw." + [thunk] + (try (thunk) nil (catch Exception e e))) + +(deftest rethrow-api-error!-passthrough-test + (testing ":api-error exceptions are rethrown unchanged" + (let [original (ex-info "boom" {:api-error true :error-code :proxy-not-configured})] + (is (identical? original + (caught #(self.core/rethrow-api-error! "anthropic" (constantly "X") original))))))) + +(deftest rethrow-api-error!-string-body-test + (testing "HTTP responses with a body get the upstream body appended and surfaced in ex-data" + (let [upstream (ex-info "clj-http error" + {:status 500 + :reason-phrase "Internal Server Error" + :headers {"content-type" "application/json"} + :body (json/encode {:error {:message "model decommissioned"}}) + :http-client (reify java.io.Closeable (close [_])) + :trace-redirects ["http://elsewhere"] + :orig-content-encoding "gzip"}) + ex (caught #(self.core/rethrow-api-error! + "anthropic" + (fn [res] (str "Anthropic API error (HTTP " (:status res) ")")) + upstream))] + (is (= "Anthropic API error (HTTP 500) — model decommissioned" (ex-message ex))) + ;; pin exact ex-data keys — clj-http internals (:http-client, :trace-redirects, …) must not leak. + (is (= #{:status :reason-phrase :headers :body :api-error :provider :error-code} + (set (keys (ex-data ex))))) + (is (=? {:api-error true :provider "anthropic" :error-code :provider-api-error + :status 500 :body {:error {:message "model decommissioned"}}} + (ex-data ex))))) + + (testing "non-JSON bodies still get a preview appended" + (let [upstream (ex-info "clj-http error" + {:status 502 :reason-phrase "Bad Gateway" + :headers {"content-type" "text/plain"} + :body "upstream gateway timeout"}) + ex (caught #(self.core/rethrow-api-error! + "openrouter" + (constantly "OpenRouter upstream provider returned an error") + upstream))] + (is (str/includes? (ex-message ex) "OpenRouter upstream provider returned an error")) + (is (str/includes? (ex-message ex) "upstream gateway timeout")) + (is (= #{:status :reason-phrase :headers :body :api-error :provider :error-code} + (set (keys (ex-data ex))))))) + + (testing "structured maps without :error/:detail/:message pr-str into the user-facing message" + (let [upstream (ex-info "clj-http error" + {:status 500 :reason-phrase "Internal Server Error" + :headers {"content-type" "application/json"} + :body (json/encode {:request-id "abc" :trace ["frame1"]})}) + ex (caught #(self.core/rethrow-api-error! + "anthropic" + (constantly "Anthropic API is not working but not saying why") + upstream))] + (is (str/includes? (ex-message ex) "Anthropic API is not working but not saying why")) + (is (str/includes? (ex-message ex) ":request-id") + "the unrecognised envelope's pr-str is appended so operators see what we got") + (is (= {:request-id "abc" :trace ["frame1"]} (:body (ex-data ex))) + "the full body is still preserved in ex-data for debugging") + (is (= #{:status :reason-phrase :headers :body :api-error :provider :error-code} + (set (keys (ex-data ex)))))))) + +(deftest rethrow-api-error!-no-body-test + (testing "non-HTTP errors (no :body) fall through to the request-failed branch" + (let [ex (caught #(self.core/rethrow-api-error! + "openai" (constantly "unused") (java.net.SocketTimeoutException. "Read timed out")))] + (is (str/includes? (ex-message ex) "API request failed")) + (is (str/includes? (ex-message ex) "Read timed out")) + ;; pin exact ex-data keys for the no-body branch too. + (is (= #{:api-error :provider :error-code :exception-class} + (set (keys (ex-data ex))))) + (is (=? {:api-error true :provider "openai" :error-code :provider-request-failed + :exception-class "java.net.SocketTimeoutException"} + (ex-data ex))))) + + (testing "no-body branch drops the trailing colon when ex-message is blank" + (let [ex (caught #(self.core/rethrow-api-error! "openai" (constantly "unused") (RuntimeException.)))] + (is (= "openai API request failed" (ex-message ex))) + (is (= #{:api-error :provider :error-code :exception-class} + (set (keys (ex-data ex)))))))) + +(deftest rethrow-api-error!-input-stream-test + (testing "InputStream JSON bodies are decoded and structured-extracted" + (let [json (json/encode {:error {:message "model decommissioned"}}) + upstream (ex-info "clj-http error" + {:status 500 :reason-phrase "Internal Server Error" + :headers {"content-type" "application/json"} + :body (java.io.ByteArrayInputStream. (.getBytes ^String json))}) + ex (caught #(self.core/rethrow-api-error! + "anthropic" + (fn [res] (str "Anthropic API error (HTTP " (:status res) ")")) + upstream))] + (is (= "Anthropic API error (HTTP 500) — model decommissioned" (ex-message ex))) + (is (=? {:error {:message "model decommissioned"}} (:body (ex-data ex)))))) + + (testing "Large InputStream bodies are bounded — not fully slurped into memory" + ;; ByteArrayInputStream.available() returns the unread byte count, so we can + ;; measure how much rethrow-api-error! pulled off the stream without proxying. + (let [body-bytes (.getBytes ^String (apply str (repeat 2000000 \x))) + stream (java.io.ByteArrayInputStream. body-bytes) + upstream (ex-info "clj-http error" + {:status 502 :reason-phrase "Bad Gateway" + :headers {"content-type" "text/plain"} + :body stream}) + ex (caught #(self.core/rethrow-api-error! + "openrouter" + (constantly "OpenRouter upstream provider returned an error") + upstream)) + consumed (- (alength body-bytes) (.available stream))] + (is (str/includes? (ex-message ex) "OpenRouter upstream provider returned an error")) + (is (str/ends-with? (ex-message ex) "…")) + (is (< consumed (alength body-bytes)) + "should not consume the entire 2MB stream just to surface an error preview"))) + + (testing "Truncated InputStream JSON bodies fall back to the raw bounded string" + ;; A small slurp cap forces the JSON to be cut mid-envelope. We should fall back + ;; to surfacing the raw bounded string rather than throwing on parse failure. + (let [json (json/encode {:error {:message (apply str (repeat 10000 \x))}}) + upstream (ex-info "clj-http error" + {:status 500 :reason-phrase "Internal Server Error" + :headers {"content-type" "application/json"} + :body (java.io.ByteArrayInputStream. (.getBytes ^String json))}) + ex (with-redefs [self.core/max-body-slurp-chars 100] + (caught #(self.core/rethrow-api-error! + "anthropic" + (constantly "Anthropic upstream provider returned an error") + upstream)))] + (is (str/includes? (ex-message ex) "Anthropic upstream provider returned an error")) + (is (str/includes? (ex-message ex) "{\"error\":{\"message\":\"xxx") + "the truncated raw string is surfaced in the user-facing message when JSON parse fails") + (is (string? (:body (ex-data ex))) + "the bounded raw string is kept on ex-data when JSON parse fails") + (is (<= (count (:body (ex-data ex))) 100) + "the body in ex-data respects the slurp cap")))) + +(deftest rethrow-api-error!-retry-after-test + (testing "Retry-After header survives the ex-data allow-list and reaches retry-delay-ms" + ;; Regression test: an earlier revision allow-listed only :status/:reason-phrase/:body, + ;; which silently dropped :headers and made provider 429/529 retries fall back to + ;; exponential backoff instead of honoring the upstream Retry-After. + (let [upstream (ex-info "clj-http error" + {:status 429 :reason-phrase "Too Many Requests" + :headers {"retry-after" "3"} + :body "rate limited"}) + ex (caught #(self.core/rethrow-api-error! + "openrouter" + (constantly "OpenRouter rate limit") + upstream))] + (is (= {"retry-after" "3"} (:headers (ex-data ex)))) + (is (<= 3000 (#'self/retry-delay-ms 1 ex) (+ 3000 750)) + "retry-delay-ms picks up the 3-second Retry-After through the rethrown exception")))) + +(deftest rethrow-api-error!-warn-log-test + (testing "the full upstream body is emitted at warn level alongside provider and status" + (let [upstream (ex-info "clj-http error" + {:status 502 :reason-phrase "Bad Gateway" + :headers {"content-type" "text/plain"} + :body "upstream gateway timeout"}) + [entry & more] + (log.capture/with-log-messages-for-level [msgs [metabase.metabot.self.core :warn]] + (caught #(self.core/rethrow-api-error! + "openrouter" + (constantly "OpenRouter upstream provider returned an error") + upstream)) + (msgs))] + (is (nil? more) "exactly one warn line at the failure boundary") + (is (=? {:level :warn :namespace 'metabase.metabot.self.core} + entry)) + (is (re-find #"provider=openrouter status=502 body=\"upstream gateway timeout\"" + (:message entry))))) + (testing "an oversized body is capped in the warn log, but preserved in full on ex-data" + (let [cap @#'self.core/max-body-log-chars + big-body (apply str (repeat (+ cap 1000) \x)) + upstream (ex-info "clj-http error" + {:status 502 :reason-phrase "Bad Gateway" + :headers {"content-type" "text/plain"} + :body big-body}) + [entry & more] + (log.capture/with-log-messages-for-level [msgs [metabase.metabot.self.core :warn]] + (let [ex (caught #(self.core/rethrow-api-error! + "openrouter" + (constantly "OpenRouter upstream provider returned an error") + upstream))] + (is (= big-body (:body (ex-data ex))) + "the full, untruncated body still survives on ex-data")) + (msgs))] + (is (nil? more) "exactly one warn line at the failure boundary") + (is (str/ends-with? (:message entry) + (str "body=" (subs (pr-str big-body) 0 cap) "…")) + "the warn line's body segment is capped at max-body-log-chars with a trailing ellipsis") + (is (not (str/includes? (:message entry) big-body)) + "the full oversized body is not spliced into the warn line")))) + +(deftest rethrow-api-error!-auth-status-body-not-leaked-test + (testing "401/403 bodies are not appended to the user-facing message (may carry sensitive auth/account detail)" + (doseq [status [401 403]] + (let [secret "sk-leaked-key-abc123 for org=acme-corp tenant=42" + upstream (ex-info "clj-http error" + {:status status + :reason-phrase "Unauthorized" + :headers {"content-type" "application/json"} + :body (json/encode {:error {:message secret}})}) + [entry & more] + (log.capture/with-log-messages-for-level [msgs [metabase.metabot.self.core :warn]] + (let [ex (caught #(self.core/rethrow-api-error! + "anthropic" + (fn [res] (str "Anthropic API error (HTTP " (:status res) ")")) + upstream))] + (is (= (str "Anthropic API error (HTTP " status ")") (ex-message ex)) + "no body preview spliced onto the user-facing message for auth statuses") + (is (not (str/includes? (ex-message ex) secret)) + "secret-bearing body must not leak into the rethrown message") + (is (= {:error {:message secret}} (:body (ex-data ex))) + "the full decoded body is still preserved on ex-data for debugging")) + (msgs))] + (is (nil? more) "exactly one warn line at the failure boundary") + (is (str/includes? (:message entry) secret) + "the full body is still emitted at warn level for server-side debugging"))))) diff --git a/test/metabase/metabot/settings_test.clj b/test/metabase/metabot/settings_test.clj index 1332c5e8b7d7..081df58ce91e 100644 --- a/test/metabase/metabot/settings_test.clj +++ b/test/metabase/metabot/settings_test.clj @@ -3,6 +3,7 @@ [clojure.test :refer [deftest is testing use-fixtures]] [metabase.llm.settings :as llm.settings] [metabase.metabot.settings :as metabot.settings] + [metabase.settings.core :as setting] [metabase.test :as mt] [metabase.test.fixtures :as fixtures])) @@ -187,3 +188,32 @@ (mt/with-premium-features #{:metabase-ai-managed} (mt/with-temporary-setting-values [llm-metabot-provider "metabase/anthropic/claude-sonnet-4-6"] (is (= "metabase/anthropic/claude-sonnet-4-6" (metabot.settings/llm-metabot-provider))))))) + +;;; ------------------------------------------- ai-usage-max-retention-days Tests ------------------------------------------- + +(deftest ai-usage-max-retention-days-default-test + (testing "defaults to 180 days when no env var is set" + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days nil] + (is (= 180 (metabot.settings/ai-usage-max-retention-days)))))) + +(deftest ai-usage-max-retention-days-infinite-test + (testing "0 is an alias for infinite retention" + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days 0] + (is (nil? (metabot.settings/ai-usage-max-retention-days)))))) + +(deftest ai-usage-max-retention-days-passthrough-test + (testing "values at or above the minimum pass through unchanged" + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days 100] + (is (= 100 (metabot.settings/ai-usage-max-retention-days)))))) + +(deftest ai-usage-max-retention-days-clamp-test + (testing "values below the minimum are clamped up to 30" + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days 1] + (is (= 30 (metabot.settings/ai-usage-max-retention-days)))))) + +(deftest ai-usage-max-retention-days-read-only-test + (testing "the setting is env-var-only and cannot be set at runtime" + (is (thrown-with-msg? + java.lang.UnsupportedOperationException + #"You cannot set ai-usage-max-retention-days" + (setting/set! :ai-usage-max-retention-days 30))))) diff --git a/test/metabase/metabot/table_utils_test.clj b/test/metabase/metabot/table_utils_test.clj index 659f081448cc..1131f6acfc15 100644 --- a/test/metabase/metabot/table_utils_test.clj +++ b/test/metabase/metabot/table_utils_test.clj @@ -64,18 +64,14 @@ (testing "exact matches with schema matching disabled" (is (table-utils/matching-tables? table1 table2 {:match-schema? false}))) - (testing "fuzzy name matches with schema matching disabled" (is (table-utils/matching-tables? table1 table3 {:match-schema? false}))) - (testing "schema matching enabled - both schemas must match" (is (table-utils/matching-tables? table1 table2 {:match-schema? true})) (is (not (table-utils/matching-tables? table1 table4 {:match-schema? true})))) - (testing "nil schema handling - should match any schema when one is nil" (is (table-utils/matching-tables? table1 table5 {:match-schema? true})) (is (table-utils/matching-tables? table5 table1 {:match-schema? true}))) - (testing "completely different names should not match" (is (not (table-utils/matching-tables? table1 table6 {:match-schema? false}))))))) @@ -99,7 +95,6 @@ :model/Field {} {:table_id table1-id, :name "name", :database_type "VARCHAR"} :model/Field {} {:table_id table2-id, :name "id", :database_type "INTEGER"} :model/Field {} {:table_id table2-id, :name "total", :database_type "DECIMAL"}] - (testing "returns tables with proper formatting" (mt/with-current-user (mt/user->id :crowberto) (let [tables (table-utils/database-tables db-id)] @@ -108,19 +103,16 @@ (is (every? #(contains? % :schema) tables)) (is (every? #(contains? % :columns) tables)) (is (every? #(every? (fn [col] (contains? col :name)) (:columns %)) tables))))) - (testing "respects all-tables-limit option" (mt/with-current-user (mt/user->id :crowberto) (let [tables (table-utils/database-tables db-id {:all-tables-limit 1})] (is (<= (count tables) 1))))) - (testing "excludes specified table IDs" (mt/with-current-user (mt/user->id :crowberto) (let [all-tables (table-utils/database-tables db-id) filtered-tables (table-utils/database-tables db-id {:exclude-table-ids #{table1-id}})] (is (< (count filtered-tables) (count all-tables))) (is (not-any? #(= table1-id (:id %)) filtered-tables))))) - (testing "prioritizes specified tables" (mt/with-current-user (mt/user->id :crowberto) (let [priority-table {:id table2-id :name "orders" :schema "public"} @@ -135,25 +127,21 @@ :model/Table {table1-id :id} {:db_id db-id, :name "users", :schema "public", :active true, :visibility_type nil} :model/Table {} {:db_id db-id, :name "user_profiles", :schema "public", :active true, :visibility_type nil} :model/Table {} {:db_id db-id, :name "orders", :schema "public", :active true, :visibility_type nil}] - (testing "finds tables with similar names" (mt/with-current-user (mt/user->id :crowberto) (let [unrecognized [{:name "user" :schema "public"}] ; similar to "users" matches (table-utils/find-matching-tables db-id unrecognized [])] (is (seq matches)) (is (some #(= "users" (:name %)) matches))))) - (testing "excludes used table IDs" (mt/with-current-user (mt/user->id :crowberto) (let [unrecognized [{:name "user" :schema "public"}] matches (table-utils/find-matching-tables db-id unrecognized [table1-id])] (is (not-any? #(= table1-id (:id %)) matches))))) - (testing "handles empty unrecognized tables" (mt/with-current-user (mt/user->id :crowberto) (let [matches (table-utils/find-matching-tables db-id [] [])] (is (empty? matches))))) - (testing "returns empty for completely different names" (mt/with-current-user (mt/user->id :crowberto) (let [unrecognized [{:name "completely_different_xyz123" :schema "public"}] @@ -166,7 +154,6 @@ :model/Table {table1-id :id} {:db_id db-id, :name "users", :schema "public", :active true, :visibility_type nil} :model/Table {} {:db_id db-id, :name "orders", :schema "public", :active true, :visibility_type nil} :model/Table {inactive-id :id} {:db_id db-id, :name "inactive_users", :schema "public", :active false, :visibility_type nil}] - (testing "handles query with recognized tables" (mt/with-current-user (mt/user->id :crowberto) ;; Mock query analyzer result with recognized table @@ -178,7 +165,6 @@ tables (table-utils/used-tables query)] (is (seq tables)) (is (some #(= table1-id (:id %)) tables)))))) - (testing "handles query with unrecognized tables that have fuzzy matches" (mt/with-current-user (mt/user->id :crowberto) ;; Mock query analyzer result with unrecognized table @@ -191,7 +177,6 @@ (is (seq tables)) ;; Should find the fuzzy match for "users" table (is (some #(= "users" (:name %)) tables)))))) - (testing "handles empty query analysis result" (mt/with-current-user (mt/user->id :crowberto) (with-redefs [query-analyzer/tables-for-native @@ -200,7 +185,6 @@ (lib/native-query (mt/metadata-provider) "SELECT 1")) tables (table-utils/used-tables query)] (is (empty? tables)))))) - (testing "handles mixed recognized and unrecognized tables" (mt/with-current-user (mt/user->id :crowberto) (with-redefs [query-analyzer/tables-for-native @@ -212,7 +196,6 @@ tables (table-utils/used-tables query)] (is (>= (count tables) 1)) ; At least the recognized table (is (some #(= table1-id (:id %)) tables)))))) - (testing "filters out recognized inactive tables" (mt/with-current-user (mt/user->id :crowberto) (let [query (mt/with-db db @@ -230,37 +213,30 @@ :model/Table {inactive-id :id} {:db_id db-id, :name "old_data", :schema "public", :active false, :visibility_type nil} :model/Table {hidden-id :id} {:db_id db-id, :name "sensitive", :schema "public", :active true, :visibility_type :hidden} :model/Table {other-db-table-id :id} {:db_id other-db-id, :name "other_table", :schema "public", :active true, :visibility_type nil}] - (testing "returns tables with correct structure for valid table-ids" (mt/with-current-user (mt/user->id :crowberto) (is (= #{{:id table1-id :name "users" :schema "public"} {:id table3-id :name "products" :schema "inventory"}} (set (table-utils/used-tables-from-ids db-id [table1-id table3-id])))))) - (testing "handles empty table-ids collection" (mt/with-current-user (mt/user->id :crowberto) (is (empty? (table-utils/used-tables-from-ids db-id []))))) - (testing "filters by database-id" (mt/with-current-user (mt/user->id :crowberto) (is (= [{:id table1-id :name "users" :schema "public"}] (table-utils/used-tables-from-ids db-id [table1-id other-db-table-id]))))) - (testing "filters out inactive tables" (mt/with-current-user (mt/user->id :crowberto) (is (= [{:id table1-id :name "users" :schema "public"}] (table-utils/used-tables-from-ids db-id [table1-id inactive-id]))))) - (testing "filters out hidden tables" (mt/with-current-user (mt/user->id :crowberto) (is (= [{:id table1-id :name "users" :schema "public"}] (table-utils/used-tables-from-ids db-id [table1-id hidden-id]))))) - (testing "handles non-existent table-ids" (mt/with-current-user (mt/user->id :crowberto) (let [fake-id 999999] (is (empty? (table-utils/used-tables-from-ids db-id [fake-id])))))) - (testing "handles mix of valid and invalid table-ids" (mt/with-current-user (mt/user->id :crowberto) (let [fake-id 999999] @@ -274,12 +250,10 @@ (deftest edge-cases-test (testing "edge cases and error handling" - (testing "similar? with special characters" (is (table-utils/similar? "user-profiles" "user_profiles")) (is (table-utils/similar? "user.table" "user_table")) (is (not (table-utils/similar? "completely@different!table" "another#table")))) - (testing "matching-tables? with nil values" (is (table-utils/matching-tables? {:name "test" :schema nil} {:name "test" :schema nil} @@ -290,23 +264,19 @@ (is (table-utils/matching-tables? {:name "test" :schema "public"} {:name "test" :schema nil} {:match-schema? true})))) - (testing "database-tables with invalid database ID" (mt/with-current-user (mt/user->id :crowberto) (is (empty? (table-utils/database-tables -1))))) - (testing "find-matching-tables with empty database" (mt/with-temp [:model/Database {db-id :id} {}] (mt/with-current-user (mt/user->id :crowberto) (let [matches (table-utils/find-matching-tables db-id [{:name "nonexistent"}] [])] (is (empty? matches)))))) - (testing "used-tables handles query analyzer exceptions" (with-redefs [query-analyzer/tables-for-native (fn [_query & _opts] (throw (Exception. "Query analysis failed")))] (let [query (lib/native-query (mt/metadata-provider) "SELECT * FROM users")] (is (thrown? Exception (table-utils/used-tables query)))))) - (testing "database-tables with inactive tables" (mt/with-temp [:model/Database {db-id :id} {} :model/Table {} {:db_id db-id, :name "active_table", :active true, :visibility_type nil} @@ -315,7 +285,6 @@ (let [tables (table-utils/database-tables db-id)] (is (every? #(not= "inactive_table" (:name %)) tables)) (is (some #(= "active_table" (:name %)) tables)))))) - (testing "database-tables with hidden tables" (mt/with-temp [:model/Database {db-id :id} {} :model/Table {} {:db_id db-id, :name "visible_table", :active true, :visibility_type nil} @@ -335,7 +304,6 @@ :model/Field {} {:table_id table2-id, :name "id", :database_type "INTEGER", :base_type :type/Integer, :semantic_type :type/PK} :model/Field {} {:table_id table2-id, :name "user_id", :database_type "INTEGER", :base_type :type/Integer, :semantic_type :type/FK, :fk_target_field_id user-id-field} :model/Field {} {:table_id table2-id, :name "total", :database_type "DECIMAL", :base_type :type/Decimal}] - (testing "returns tables with new enhanced formatting" (mt/with-current-user (mt/user->id :crowberto) (let [tables (table-utils/enhanced-database-tables db-id)] @@ -351,7 +319,6 @@ (is (every? #(every? (fn [field] (contains? field :base_type)) (:fields %)) tables)) (is (every? #(every? (fn [field] (contains? field :database_type)) (:fields %)) tables)) (is (every? #(contains? % :metrics) tables))))) - (testing "includes table_reference for implicitly joined fields" (mt/dataset test-data (mt/with-current-user (mt/user->id :crowberto) @@ -366,19 +333,16 @@ (is (some #(= "NAME" (:name %)) user-fields) "Expected to find User NAME field from implicit join") (is (seq product-fields) "Expected to find fields with table-reference 'Product' from implicit join") (is (some #(= "TITLE" (:name %)) product-fields) "Expected to find Product TITLE field from implicit join"))))) - (testing "enhanced format respects all-tables-limit option" (mt/with-current-user (mt/user->id :crowberto) (let [tables (table-utils/enhanced-database-tables db-id {:all-tables-limit 1})] (is (<= (count tables) 1))))) - (testing "enhanced format excludes specified table IDs" (mt/with-current-user (mt/user->id :crowberto) (let [all-tables (table-utils/enhanced-database-tables db-id) filtered-tables (table-utils/enhanced-database-tables db-id {:exclude-table-ids #{table1-id}})] (is (< (count filtered-tables) (count all-tables))) (is (not-any? #(= table1-id (:id %)) filtered-tables))))) - (testing "enhanced format prioritizes specified tables" (mt/with-current-user (mt/user->id :crowberto) (let [priority-table {:id table2-id :name "orders" :schema "public"} diff --git a/test/metabase/metabot/task/metabot_conversation_trimmer_test.clj b/test/metabase/metabot/task/metabot_conversation_trimmer_test.clj new file mode 100644 index 000000000000..6e5f2213bde2 --- /dev/null +++ b/test/metabase/metabot/task/metabot_conversation_trimmer_test.clj @@ -0,0 +1,93 @@ +(ns metabase.metabot.task.metabot-conversation-trimmer-test + (:require + [clojure.test :refer :all] + [java-time.api :as t] + [metabase.metabot.task.metabot-conversation-trimmer :as metabot-conversation-trimmer] + [metabase.test :as mt] + [metabase.test.fixtures :as fixtures] + [toucan2.core :as t2])) + +(use-fixtures :once (fixtures/initialize :db)) + +(set! *warn-on-reflection* true) + +(defn- conversation-attrs + [user-id created-at] + {:id (str (random-uuid)) + :user_id user-id + :created_at created-at}) + +(defn- message-attrs + [conversation-id created-at] + {:conversation_id conversation-id + :role "user" + :profile_id "trimmer-test" + :total_tokens 0 + :data [] + :external_id (str (random-uuid)) + :created_at created-at}) + +(deftest trims-conversations-older-than-default-retention-test + (testing "with default retention (180 days), only conversations older than 180 days are deleted" + (let [user-id (mt/user->id :crowberto) + now (t/offset-date-time)] + (mt/with-temp + [:model/MetabotConversation {recent-id :id} (conversation-attrs user-id now) + :model/MetabotConversation {old-id :id} (conversation-attrs user-id (t/minus now (t/days 200)))] + (#'metabot-conversation-trimmer/trim-old-conversations!) + (is (= #{recent-id} + (t2/select-fn-set :id :model/MetabotConversation + {:where [:in :id [recent-id old-id]]}))))))) + +(deftest trims-conversations-older-than-custom-retention-test + (testing "with retention set to 30 days, conversations older than 30 days are deleted" + (let [user-id (mt/user->id :crowberto) + now (t/offset-date-time)] + (mt/with-temp + [:model/MetabotConversation {recent-id :id} (conversation-attrs user-id now) + :model/MetabotConversation {boundary-id :id} (conversation-attrs user-id (t/minus now (t/days 31))) + :model/MetabotConversation {old-id :id} (conversation-attrs user-id (t/minus now (t/days 200)))] + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days 30] + (#'metabot-conversation-trimmer/trim-old-conversations!) + (is (= #{recent-id} + (t2/select-fn-set :id :model/MetabotConversation + {:where [:in :id [recent-id boundary-id old-id]]})))))))) + +(deftest skips-deletion-when-retention-is-infinite-test + (testing "when retention is set to 0 (infinite), nothing is deleted" + (let [user-id (mt/user->id :crowberto) + now (t/offset-date-time)] + (mt/with-temp + [:model/MetabotConversation {recent-id :id} (conversation-attrs user-id now) + :model/MetabotConversation {old-id :id} (conversation-attrs user-id (t/minus now (t/years 5)))] + (mt/with-temp-env-var-value! [mb-ai-usage-max-retention-days 0] + (#'metabot-conversation-trimmer/trim-old-conversations!) + (is (= #{recent-id old-id} + (t2/select-fn-set :id :model/MetabotConversation + {:where [:in :id [recent-id old-id]]})))))))) + +(deftest cascades-to-messages-test + (testing "deleting an old conversation cascades to its messages via ON DELETE CASCADE" + (let [user-id (mt/user->id :crowberto) + now (t/offset-date-time)] + (mt/with-temp + [:model/MetabotConversation {old-id :id} (conversation-attrs user-id (t/minus now (t/days 200))) + :model/MetabotMessage {msg-id :id} (message-attrs old-id (t/minus now (t/days 200)))] + (#'metabot-conversation-trimmer/trim-old-conversations!) + (is (empty? (t2/select-fn-set :id :model/MetabotConversation :id old-id)) + "expired conversation deleted") + (is (empty? (t2/select-fn-set :id :model/MetabotMessage :id msg-id)) + "messages of expired conversation cascaded away"))))) + +(deftest mixed-age-messages-in-old-conversation-test + (testing "an expired conversation is deleted along with all its messages — even fresh ones — because the cascade follows the parent" + (let [user-id (mt/user->id :crowberto) + now (t/offset-date-time)] + (mt/with-temp + [:model/MetabotConversation {old-id :id} (conversation-attrs user-id (t/minus now (t/days 200))) + :model/MetabotMessage {old-msg :id} (message-attrs old-id (t/minus now (t/days 200))) + :model/MetabotMessage {fresh-msg :id} (message-attrs old-id now)] + (#'metabot-conversation-trimmer/trim-old-conversations!) + (is (empty? (t2/select-fn-set :id :model/MetabotConversation :id old-id))) + (is (empty? (t2/select-fn-set :id :model/MetabotMessage + {:where [:in :id [old-msg fresh-msg]]}))))))) diff --git a/test/metabase/metabot/task/suggested_prompts_generator_test.clj b/test/metabase/metabot/task/suggested_prompts_generator_test.clj index ccf457e1e34f..dc13b731ab7d 100644 --- a/test/metabase/metabot/task/suggested_prompts_generator_test.clj +++ b/test/metabase/metabot/task/suggested_prompts_generator_test.clj @@ -38,20 +38,17 @@ "How has this metric changed over time?"]}]} {:table_questions [] :metric_questions []}))] - (testing "Non-verified card with use_verified_content=false generates prompts" (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content false}) (#'metabot.task.suggested-prompts-generator/maybe-generate-suggested-prompts!) (let [prompts (t2/select :model/MetabotPrompt :card_id card-id)] (is (seq prompts)))) - (testing "Non-verified card with use_verified_content=true generates no prompts" (t2/delete! :model/MetabotPrompt) (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content true}) (#'metabot.task.suggested-prompts-generator/maybe-generate-suggested-prompts!) (let [prompts (t2/select :model/MetabotPrompt :card_id card-id)] (is (empty? prompts)))) - (testing "Verified card generates prompts regardless of use_verified_content" (mt/with-temp [:model/ModerationReview @@ -61,22 +58,19 @@ :moderated_item_type "card" :status "verified" :most_recent true}] - (testing "with use_verified_content=true" (t2/delete! :model/MetabotPrompt) (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content true}) (#'metabot.task.suggested-prompts-generator/maybe-generate-suggested-prompts!) (let [prompts (t2/select :model/MetabotPrompt :card_id card-id)] (is (seq prompts)))) - (testing "with use_verified_content=false" (t2/delete! :model/MetabotPrompt) (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content false}) (#'metabot.task.suggested-prompts-generator/maybe-generate-suggested-prompts!) (let [prompts (t2/select :model/MetabotPrompt :card_id card-id)] (is (seq prompts)))))) - - ;; Reset metabot state + ;; Reset metabot state (t2/update! :model/Metabot (:id original-metabot) {:use_verified_content false})))))))) (deftest suggested-prompts-generator-skips-generation-when-managed-provider-is-locked-test diff --git a/test/metabase/metabot/tools/autogen_dashboard_test.clj b/test/metabase/metabot/tools/autogen_dashboard_test.clj index 7b20c785c232..10ce7b1d24ab 100644 --- a/test/metabase/metabot/tools/autogen_dashboard_test.clj +++ b/test/metabase/metabot/tools/autogen_dashboard_test.clj @@ -16,7 +16,6 @@ (autogen-dashboard/create-autogenerated-dashboard {:source {} :memory-atom memory-atom}))))) - (testing "fails when query_id not found in memory" (let [memory-atom (atom {:state {:queries {}}})] (is (thrown-with-msg? @@ -37,7 +36,6 @@ {:source {:table_id (:id table)} :memory-atom (atom {:state {}})})] (is (string? (:instructions result)))))) - (testing "returns redirect reaction" (mt/with-temp [:model/Table table {:name (mt/random-name) :db_id (mt/id)}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -46,7 +44,6 @@ (is (contains? result :reactions)) (is (= :metabot.reaction/redirect (:type (first (:reactions result))))) (is (str/starts-with? (:url (first (:reactions result))) "/auto/dashboard/"))))) - (testing "returns user-friendly message" (mt/with-temp [:model/Table table {:name (mt/random-name) :db_id (mt/id)}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -54,7 +51,6 @@ :memory-atom (atom {:state {}})})] (is (string? (get-in result [:structured-output :message]))) (is (str/includes? (get-in result [:structured-output :message]) "dashboard"))))) - (testing "returns path in structured output" (mt/with-temp [:model/Table table {:name (mt/random-name) :db_id (mt/id)}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -72,7 +68,6 @@ :memory-atom (atom {:state {}})})] (is (= (str "/auto/dashboard/table/" (:id table)) (get-in result [:structured-output :path])))))) - (testing "model source generates correct path" (mt/with-temp [:model/Card model {:type :model :name "test_model"}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -80,7 +75,6 @@ :memory-atom (atom {:state {}})})] (is (= (str "/auto/dashboard/model/" (:id model)) (get-in result [:structured-output :path])))))) - (testing "metric source generates correct path" (mt/with-temp [:model/Card metric {:type :metric :name "test_metric"}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -88,7 +82,6 @@ :memory-atom (atom {:state {}})})] (is (= (str "/auto/dashboard/metric/" (:id metric)) (get-in result [:structured-output :path])))))) - (testing "report source generates correct path" (mt/with-temp [:model/Card report {:type :question :name "test_report"}] (let [result (autogen-dashboard/create-autogenerated-dashboard @@ -96,7 +89,6 @@ :memory-atom (atom {:state {}})})] (is (= (str "/auto/dashboard/question/" (:id report)) (get-in result [:structured-output :path])))))) - (testing "query source generates correct path" (let [query {:lib/type :mbql/query :database 1 :stages []} memory-atom (atom {:state {:queries {"q123" query}}})] @@ -115,7 +107,6 @@ (autogen-dashboard/create-autogenerated-dashboard {:source {:table_id 999999} :memory-atom (atom {:state {}})})))) - (testing "fails for nonexistent model" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -123,7 +114,6 @@ (autogen-dashboard/create-autogenerated-dashboard {:source {:model_id 999999} :memory-atom (atom {:state {}})})))) - (testing "fails for nonexistent metric" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -131,7 +121,6 @@ (autogen-dashboard/create-autogenerated-dashboard {:source {:metric_id 999999} :memory-atom (atom {:state {}})})))) - (testing "fails for nonexistent report" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/metabot/tools/charts/create_test.clj b/test/metabase/metabot/tools/charts/create_test.clj index 076d1f286a5f..faed6322568e 100644 --- a/test/metabase/metabot/tools/charts/create_test.clj +++ b/test/metabase/metabot/tools/charts/create_test.clj @@ -21,7 +21,6 @@ (is (str/includes? (:chart-content result) "bar")) (is (str/starts-with? (:chart-link result) "metabase://chart/")) (is (contains? result :instructions)))) - (testing "creates chart with different types" (let [queries-state {"q-456" {:query-id "q-456" :sql "SELECT COUNT(*) FROM users" @@ -33,7 +32,6 @@ :queries-state queries-state})] (is (= chart-type (:chart-type result)) (str "Chart type " chart-type " should be set correctly")))))) - (testing "throws error for invalid chart type" (let [queries-state {"q-789" {:query-id "q-789" :query-content "SELECT 1" @@ -45,7 +43,6 @@ {:query-id "q-789" :chart-type :invalid-type :queries-state queries-state}))))) - (testing "throws error when query not found" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/metabot/tools/charts/edit_test.clj b/test/metabase/metabot/tools/charts/edit_test.clj index 45d1c823c893..28d1bfecd63f 100644 --- a/test/metabase/metabot/tools/charts/edit_test.clj +++ b/test/metabase/metabot/tools/charts/edit_test.clj @@ -31,7 +31,6 @@ (is (str/includes? (:chart-content result) "line")) (is (str/starts-with? (:chart-link result) "metabase://chart/")) (is (contains? result :instructions)))) - (testing "edits chart to various types" (let [mp (mt/metadata-provider) charts-state {"chart-456" {:chart-id "chart-456" @@ -43,7 +42,6 @@ :charts-state charts-state})] (is (= new-type (:chart-type result)) (str "New chart type " new-type " should be set correctly")))))) - (testing "throws error for invalid chart type" (let [charts-state {"chart-789" {:chart-id "chart-789"}}] (is (thrown-with-msg? @@ -53,7 +51,6 @@ {:chart-id "chart-789" :new-chart-type :invalid-type :charts-state charts-state}))))) - (testing "throws error when chart not found" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/metabot/tools/clarification_test.clj b/test/metabase/metabot/tools/clarification_test.clj index def2f5cdb0bf..ba4bde17485f 100644 --- a/test/metabase/metabot/tools/clarification_test.clj +++ b/test/metabase/metabot/tools/clarification_test.clj @@ -9,21 +9,17 @@ (let [result (ask-clarification/ask-for-sql-clarification {:question "What table should I use?"})] (is (map? result)) (is (true? (:final-response? result))))) - (testing "returns question in structured output" (let [result (ask-clarification/ask-for-sql-clarification {:question "What columns do you need?"})] (is (= "What columns do you need?" (get-in result [:structured-output :question]))))) - (testing "returns options in structured output when provided" (let [result (ask-clarification/ask-for-sql-clarification {:question "Which table?" :options ["users" "orders" "products"]})] (is (= ["users" "orders" "products"] (get-in result [:structured-output :options]))))) - (testing "returns empty options when not provided" (let [result (ask-clarification/ask-for-sql-clarification {:question "What do you want?"})] (is (= [] (get-in result [:structured-output :options]))))) - (testing "includes instructions for LLM" (let [result (ask-clarification/ask-for-sql-clarification {:question "Any question?"})] (is (contains? result :instructions)) diff --git a/test/metabase/metabot/tools/deftool_test.clj b/test/metabase/metabot/tools/deftool_test.clj index 9cd90288a407..1c97c47f0247 100644 --- a/test/metabase/metabot/tools/deftool_test.clj +++ b/test/metabase/metabot/tools/deftool_test.clj @@ -22,7 +22,6 @@ (is (= {:metabot-id "bot-456"} @received-args) "Handler should receive metabot-id in args") (is (= "conv-123" (:conversation_id result))) (is (= {:message "hello"} (:structured_output result))))) - (testing "invoke-tool with no arguments schema and no metabot-id" (let [received-args (atom nil) handler (fn [args] @@ -88,7 +87,6 @@ (is (= 'metabase.api.macros/defendpoint (first expansion))) (is (= :post (second expansion))) (is (= "/test-endpoint" (nth expansion 2))))) - (testing "deftool macro expands correctly for no-args tool" (let [expansion (macroexpand-1 '(metabase.metabot.tools.deftool/deftool "/no-args" "No args tool" @@ -96,7 +94,6 @@ :handler identity}))] (is (seq? expansion)) (is (= 'metabase.api.macros/defendpoint (first expansion))))) - (testing "deftool macro always includes request in binding vector" (let [expansion (macroexpand-1 '(metabase.metabot.tools.deftool/deftool "/test" "Test" diff --git a/test/metabase/metabot/tools/document_test.clj b/test/metabase/metabot/tools/document_test.clj index 7b5473f3b872..355a94b3591e 100644 --- a/test/metabase/metabot/tools/document_test.clj +++ b/test/metabase/metabot/tools/document_test.clj @@ -23,13 +23,11 @@ (is (= {:database_id 1 :sql_engine "h2"} (:structured-output result)))))) - (testing "returns missing-database message when no database references are present" (with-redefs [shared/current-context (fn [] {:references {}})] (let [result (document-tools/document-schema-collect-tool {})] (is (= "You must `@` mention a database to use when not querying an existing model" (:output result)))))) - (testing "returns multiple-database message when more than one database is referenced" (with-redefs [shared/current-context (fn [] {:references {"database:1" "Test DB 1" "database:2" "Test DB 2"}})] @@ -70,7 +68,6 @@ :native {:query "SELECT * FROM test" :template-tags {}}} (:dataset_query structured)))))) - (testing "returns instructions when SQL validation fails" (with-redefs [create-sql-query-tools/create-sql-query (fn [_] @@ -89,7 +86,6 @@ (is (nil? (:structured-output result))) (is (re-find #"SQL chart draft generation failed" (:output result))) (is (re-find #"syntax error near FROM" (:output result)))))) - (testing "returns instructions when query processor rejects generated SQL" (with-redefs [create-sql-query-tools/create-sql-query (fn [_] diff --git a/test/metabase/metabot/tools/entity_details_test.clj b/test/metabase/metabot/tools/entity_details_test.clj index 9a804a7341f0..fdc6c8a3d9b0 100644 --- a/test/metabase/metabot/tools/entity_details_test.clj +++ b/test/metabase/metabot/tools/entity_details_test.clj @@ -150,10 +150,8 @@ output (:structured-output result) related-tables (:related_tables output) products-related (first (filter #(= products-id (:id %)) related-tables))] - (testing "Orders table has Products as a related table" (is (some? products-related))) - (testing "Related Products table has correct number of fields (excluding implicitly joinable fields)" (is (= expected-products-field-count (count (:fields products-related))) @@ -190,7 +188,6 @@ :entity-id (mt/id :orders)}) output (:structured-output result)] (is (nil? (:measures output))))) - (testing "with_measures: true includes measures for the table" (let [result (entity-details/get-table-details {:entity-type :table :entity-id (mt/id :orders) @@ -217,7 +214,6 @@ :entity-id (mt/id :orders)}) output (:structured-output result)] (is (nil? (:segments output))))) - (testing "with_segments: true includes segments for the table" (let [result (entity-details/get-table-details {:entity-type :table :entity-id (mt/id :orders) @@ -271,7 +267,6 @@ (let [result (entity-details/get-metric-details {:metric-id metric-id}) output (:structured-output result)] (is (nil? (:segments output))))) - (testing "with_segments: true includes segments for the metric" (let [result (entity-details/get-metric-details {:metric-id metric-id :with-segments? true}) diff --git a/test/metabase/metabot/tools/navigation_test.clj b/test/metabase/metabot/tools/navigation_test.clj index 769ca86e388d..98ac15face79 100644 --- a/test/metabase/metabot/tools/navigation_test.clj +++ b/test/metabase/metabot/tools/navigation_test.clj @@ -12,27 +12,22 @@ (let [result (navigate/navigate {:destination {:page "notebook_editor"} :memory-atom memory-atom})] (is (= "/question/notebook" (get-in result [:structured-output :path]))))) - (testing "metrics_browser" (let [result (navigate/navigate {:destination {:page "metrics_browser"} :memory-atom memory-atom})] (is (= "/browse/metrics" (get-in result [:structured-output :path]))))) - (testing "model_browser" (let [result (navigate/navigate {:destination {:page "model_browser"} :memory-atom memory-atom})] (is (= "/browse/models" (get-in result [:structured-output :path]))))) - (testing "database_browser" (let [result (navigate/navigate {:destination {:page "database_browser"} :memory-atom memory-atom})] (is (= "/browse/databases" (get-in result [:structured-output :path]))))) - (testing "home" (let [result (navigate/navigate {:destination {:page "home"} :memory-atom memory-atom})] (is (= "/" (get-in result [:structured-output :path]))))) - (testing "sql_editor with database_id" (let [result (navigate/navigate {:destination {:page "sql_editor" :database_id 123} :memory-atom memory-atom})] @@ -46,32 +41,26 @@ (let [result (navigate/navigate {:destination {:entity_type "table" :entity_id 42} :memory-atom memory-atom})] (is (= "/table/42" (get-in result [:structured-output :path]))))) - (testing "model navigation" (let [result (navigate/navigate {:destination {:entity_type "model" :entity_id 100} :memory-atom memory-atom})] (is (= "/model/100" (get-in result [:structured-output :path]))))) - (testing "question navigation" (let [result (navigate/navigate {:destination {:entity_type "question" :entity_id 55} :memory-atom memory-atom})] (is (= "/question/55" (get-in result [:structured-output :path]))))) - (testing "metric navigation" (let [result (navigate/navigate {:destination {:entity_type "metric" :entity_id 77} :memory-atom memory-atom})] (is (= "/metric/77" (get-in result [:structured-output :path]))))) - (testing "dashboard navigation" (let [result (navigate/navigate {:destination {:entity_type "dashboard" :entity_id 88} :memory-atom memory-atom})] (is (= "/dashboard/88" (get-in result [:structured-output :path]))))) - (testing "database navigation" (let [result (navigate/navigate {:destination {:entity_type "database" :entity_id 99} :memory-atom memory-atom})] (is (= "/browse/databases/99" (get-in result [:structured-output :path]))))) - (testing "collection navigation" (let [result (navigate/navigate {:destination {:entity_type "collection" :entity_id 111} :memory-atom memory-atom})] @@ -88,7 +77,6 @@ (is (=? {:structured-output {:path #(str/starts-with? % "/question#")}} (navigate/navigate {:destination {:query_id "test-query-id"} :memory-atom memory-atom}))))) - (testing "query navigation fails for missing query" (let [memory-atom (atom {:state {:queries {} :charts {}}})] (is (thrown-with-msg? @@ -106,7 +94,6 @@ (is (=? {:structured-output {:path #(str/starts-with? % "/question#")}} (navigate/navigate {:destination {:chart_id "chart1"} :memory-atom memory-atom}))))) - (testing "chart navigation falls back to query lookup if chart not found" (let [query {:lib/type :mbql/query :database 1 :stages [{:lib/type :mbql.stage/mbql :source-table 10}]} memory-atom (atom {:state {:queries {"chart1" query} @@ -114,7 +101,6 @@ (is (=? {:structured-output {:path #(str/starts-with? % "/question#")}} (navigate/navigate {:destination {:chart_id "chart1"} :memory-atom memory-atom}))))) - (testing "chart navigation fails for missing chart and query" (let [memory-atom (atom {:state {:queries {} :charts {}}})] (is (thrown-with-msg? @@ -141,7 +127,6 @@ (let [result (navigate/navigate {:destination {:page "home"} :memory-atom memory-atom})] (is (string? (get-in result [:structured-output :message]))))) - (testing "entity navigation message" (let [result (navigate/navigate {:destination {:entity_type "model" :entity_id 1} :memory-atom memory-atom})] diff --git a/test/metabase/metabot/tools/resources_test.clj b/test/metabase/metabot/tools/resources_test.clj index e313fbe451be..9a251932158b 100644 --- a/test/metabase/metabot/tools/resources_test.clj +++ b/test/metabase/metabot/tools/resources_test.clj @@ -7,66 +7,63 @@ [metabase.lib.core :as lib] [metabase.lib.metadata :as lib.metadata] [metabase.metabot.tools.resources :as read-resource] + [metabase.metabot.tools.shared.llm-representations :as llm-rep] + [metabase.models.interface :as mi] [metabase.query-processor :as qp] - [metabase.test :as mt])) + [metabase.test :as mt] + [metabase.transforms.core :as transforms.core] + [toucan2.core :as t2])) (deftest parse-uri-test - (testing "parses table URI" - (is (= {:resource-type "table" - :resource-id "123" - :sub-resource nil - :sub-resource-id nil} - (#'read-resource/parse-uri "metabase://table/123")))) - - (testing "parses table with fields sub-resource" - (is (= {:resource-type "table" - :resource-id "123" - :sub-resource "fields" - :sub-resource-id nil} - (#'read-resource/parse-uri "metabase://table/123/fields")))) - - (testing "parses table with specific field" - (is (= {:resource-type "table" - :resource-id "123" - :sub-resource "fields" - :sub-resource-id "456"} - (#'read-resource/parse-uri "metabase://table/123/fields/456")))) - - (testing "parses field ID with slash" - (is (= {:resource-type "table" - :resource-id "123" - :sub-resource "fields" - :sub-resource-id "c75/17"} - (#'read-resource/parse-uri "metabase://table/123/fields/c75/17")))) - - (testing "parses model URI" - (is (= {:resource-type "model" - :resource-id "456" - :sub-resource nil - :sub-resource-id nil} - (#'read-resource/parse-uri "metabase://model/456")))) - - (testing "parses question URI" - (is (= {:resource-type "question" - :resource-id "456" - :sub-resource nil - :sub-resource-id nil} + (testing "parses single-segment URIs (top-level lists)" + (is (= {:segments ["databases"] :query-params nil} + (#'read-resource/parse-uri "metabase://databases"))) + (is (= {:segments ["collections"] :query-params nil} + (#'read-resource/parse-uri "metabase://collections")))) + (testing "parses entity URIs into [type id] segments" + (is (= {:segments ["table" "123"] :query-params nil} + (#'read-resource/parse-uri "metabase://table/123"))) + (is (= {:segments ["model" "456"] :query-params nil} + (#'read-resource/parse-uri "metabase://model/456"))) + (is (= {:segments ["question" "456"] :query-params nil} (#'read-resource/parse-uri "metabase://question/456")))) - - (testing "parses metric with dimensions" - (is (= {:resource-type "metric" - :resource-id "789" - :sub-resource "dimensions" - :sub-resource-id nil} + (testing "parses entity sub-resources" + (is (= {:segments ["table" "123" "fields"] :query-params nil} + (#'read-resource/parse-uri "metabase://table/123/fields"))) + (is (= {:segments ["table" "123" "fields" "456"] :query-params nil} + (#'read-resource/parse-uri "metabase://table/123/fields/456"))) + (is (= {:segments ["metric" "789" "dimensions"] :query-params nil} (#'read-resource/parse-uri "metabase://metric/789/dimensions")))) - + (testing "parses field IDs that contain slashes (e.g. c75/17)" + (is (= {:segments ["table" "123" "fields" "c75" "17"] :query-params nil} + (#'read-resource/parse-uri "metabase://table/123/fields/c75/17")))) + (testing "parses deep paths" + (is (= {:segments ["database" "1" "schemas" "PUBLIC" "tables"] :query-params nil} + (#'read-resource/parse-uri "metabase://database/1/schemas/PUBLIC/tables")))) + (testing "parses query strings into :query-params" + (is (= {:tree "true"} + (:query-params (#'read-resource/parse-uri "metabase://collections?tree=true")))) + (is (= {:tree "true" :foo "bar"} + (:query-params (#'read-resource/parse-uri "metabase://collections?tree=true&foo=bar"))))) + (testing "parses user URIs" + (is (= {:segments ["user" "recent-items"] :query-params nil} + (#'read-resource/parse-uri "metabase://user/recent-items")))) (testing "rejects invalid scheme" (is (thrown? Exception (#'read-resource/parse-uri "https://example.com")))) - - (testing "rejects incomplete URI" + (testing "rejects empty path" (is (thrown? Exception - (#'read-resource/parse-uri "metabase://table"))))) + (#'read-resource/parse-uri "metabase://")))) + (testing "URL-decodes path segments — schema names containing '/' round-trip" + ;; An encoded URI like /schemas/weird%2Fname/tables splits into 5 segments, + ;; with the schema segment decoded back to its literal form (containing '/'). + (let [parsed (#'read-resource/parse-uri "metabase://database/1/schemas/weird%2Fname/tables")] + (is (= ["database" "1" "schemas" "weird/name" "tables"] (:segments parsed)))) + (testing "round-trips through metabase-uri" + (let [uri (llm-rep/metabase-uri :database 1 "schemas" "weird/name" "tables") + parsed (#'read-resource/parse-uri uri)] + (is (= "metabase://database/1/schemas/weird%2Fname/tables" uri)) + (is (= ["database" "1" "schemas" "weird/name" "tables"] (:segments parsed))))))) (deftest read-resource-validation-test (testing "rejects too many URIs" @@ -74,6 +71,115 @@ (is (thrown-with-msg? Exception #"Too many URIs" (read-resource/read-resource {:uris uris})))))) +;; ===== Dispatch routing — every URI pattern routes to the expected handler ===== + +(def ^:private dispatch-cases + "Each row: [uri expected-handler-tag expected-handler-args]. Adding a new URI pattern + to the dispatch should mean adding one row here. Args are positional and string-typed + the way the dispatch passes them to the handler." + [;; ----- Top-level navigation ----- + ["metabase://databases" :databases-list []] + ["metabase://collections" :collections-list [nil]] + ["metabase://collections?tree=true" :collections-list [{:tree "true"}]] + ["metabase://collections?tree=true&foo=bar" :collections-list [{:tree "true" :foo "bar"}]] + ["metabase://user/recent-items" :user-recents []] + ;; ----- Database drill-down ----- + ["metabase://database/1" :database ["1"]] + ["metabase://database/1/tables" :database-tables ["1"]] + ["metabase://database/1/models" :database-models ["1"]] + ["metabase://database/1/schemas" :database-schemas ["1"]] + ["metabase://database/1/schemas/PUBLIC/tables" :database-schema-tables ["1" "PUBLIC"]] + ["metabase://database/1/schemas/lower_case/tables" :database-schema-tables ["1" "lower_case"]] + ;; ----- Collection drill-down ----- + ["metabase://collection/2" :collection ["2"]] + ["metabase://collection/2/items" :collection-items ["2"]] + ["metabase://collection/2/subcollections" :collection-subcollections ["2"]] + ;; ----- Table ----- + ["metabase://table/3" :table ["3"]] + ["metabase://table/3/fields" :table-fields ["3"]] + ["metabase://table/3/fields/42" :table-field ["3" "42"]] + ["metabase://table/3/fields/c75/17" :table-field ["3" "c75/17"]] + ["metabase://table/3/derived" :table-derived ["3"]] + ;; ----- Model (a card type) ----- + ["metabase://model/4" :card ["model" "4"]] + ["metabase://model/4/fields" :card-fields ["model" "4"]] + ["metabase://model/4/fields/99" :card-field ["model" "4" "99"]] + ["metabase://model/4/fields/c75/17" :card-field ["model" "4" "c75/17"]] + ["metabase://model/4/sources" :card-sources ["4"]] + ;; ----- Question (a card type) ----- + ["metabase://question/5" :card ["question" "5"]] + ["metabase://question/5/fields" :card-fields ["question" "5"]] + ["metabase://question/5/fields/99" :card-field ["question" "5" "99"]] + ["metabase://question/5/sources" :card-sources ["5"]] + ;; ----- Metric ----- + ["metabase://metric/6" :metric ["6"]] + ["metabase://metric/6/dimensions" :metric-dimensions ["6"]] + ["metabase://metric/6/dimensions/dim-1" :metric-dimension ["6" "dim-1"]] + ;; ----- Transform ----- + ["metabase://transform/7" :transform ["7"]] + ["metabase://transform/7/sources" :transform-sources ["7"]] + ["metabase://transform/7/target" :transform-target ["7"]] + ;; ----- Dashboard ----- + ["metabase://dashboard/8" :dashboard ["8"]] + ["metabase://dashboard/8/items" :dashboard-items ["8"]]]) + +(deftest dispatch-routing-test + (testing "every supported URI pattern routes to the expected handler with the expected args" + (let [calls (atom nil) + spy (fn [tag] (fn [& args] (reset! calls [tag (vec args)]) :spied))] + (with-redefs [read-resource/fetch-databases-list (spy :databases-list) + read-resource/fetch-collections-list (spy :collections-list) + read-resource/fetch-user-recents (spy :user-recents) + read-resource/fetch-database (spy :database) + read-resource/fetch-database-tables (spy :database-tables) + read-resource/fetch-database-models (spy :database-models) + read-resource/fetch-database-schemas (spy :database-schemas) + read-resource/fetch-database-schema-tables (spy :database-schema-tables) + read-resource/fetch-collection (spy :collection) + read-resource/fetch-collection-items (spy :collection-items) + read-resource/fetch-collection-subcollections (spy :collection-subcollections) + read-resource/fetch-table (spy :table) + read-resource/fetch-table-fields (spy :table-fields) + read-resource/fetch-table-field (spy :table-field) + read-resource/fetch-table-derived (spy :table-derived) + read-resource/fetch-card (spy :card) + read-resource/fetch-card-fields (spy :card-fields) + read-resource/fetch-card-field (spy :card-field) + read-resource/fetch-card-sources (spy :card-sources) + read-resource/fetch-metric (spy :metric) + read-resource/fetch-metric-dimensions (spy :metric-dimensions) + read-resource/fetch-metric-dimension (spy :metric-dimension) + read-resource/fetch-transform (spy :transform) + read-resource/fetch-transform-sources (spy :transform-sources) + read-resource/fetch-transform-target (spy :transform-target) + read-resource/fetch-dashboard (spy :dashboard) + read-resource/fetch-dashboard-items (spy :dashboard-items)] + (doseq [[uri expected-handler expected-args] dispatch-cases] + (testing uri + (reset! calls nil) + (#'read-resource/dispatch uri) + (is (= [expected-handler expected-args] @calls)))))))) + +(deftest dispatch-rejects-unknown-uri-test + (testing "unknown top-level resource type throws" + (is (thrown-with-msg? Exception #"Unsupported URI" + (#'read-resource/dispatch "metabase://nonsense/1")))) + (testing "known type with unknown sub-resource throws" + (is (thrown-with-msg? Exception #"Unsupported URI" + (#'read-resource/dispatch "metabase://table/1/nonsense")))) + (testing "deep path that doesn't match any pattern throws" + (is (thrown-with-msg? Exception #"Unsupported URI" + (#'read-resource/dispatch "metabase://database/1/schemas/PUBLIC/cards")))) + (testing "extra-deep collection path throws" + (is (thrown-with-msg? Exception #"Unsupported URI" + (#'read-resource/dispatch "metabase://collection/1/items/extra")))) + (testing "user URI with unknown sub throws" + (is (thrown-with-msg? Exception #"Unsupported URI" + (#'read-resource/dispatch "metabase://user/bookmarks")))) + (testing "non-metabase scheme throws via parse-uri" + (is (thrown? Exception + (#'read-resource/dispatch "https://example.com"))))) + (comment (mt/with-current-user (mt/user->id :crowberto) (read-resource/read-resource @@ -88,19 +194,16 @@ (is (=? {:resources [{:content {:structured-output map?}}]} (read-resource/read-resource {:uris [(str "metabase://table/" table-id)]})))) - (testing "fetches table with fields" (is (=? {:resources [{:content {:structured-output map?}}]} (read-resource/read-resource {:uris [(str "metabase://table/" table-id "/fields")]})))) - (testing "handles multiple URIs" (is (=? {:resources [{:content {:structured-output map?}} {:content {:structured-output map?}}]} (read-resource/read-resource {:uris [(str "metabase://table/" table-id) (str "metabase://table/" table-id "/fields")]})))) - (testing "returns errors for invalid URIs" (is (=? {:resources [{:error string?}]} (read-resource/read-resource @@ -115,11 +218,9 @@ (is (=? {:resources [{:content {:structured-output map?}}]} result)) (is (str/includes? (:output result) dashboard-name)))) - (testing "rejects sub-resources" (is (=? {:resources [{:error string?}]} (read-resource/read-resource {:uris [(str "metabase://dashboard/" dashboard-id "/cards")]})))) - (testing "returns error for unknown dashboard" (is (=? {:resources [{:error string?}]} (read-resource/read-resource {:uris ["metabase://dashboard/99999"]}))))))) @@ -137,15 +238,358 @@ (is (=? {:resources [{:content {:structured-output map?}}]} result)) (is (str/includes? (:output result) transform-name)))) - (testing "rejects sub-resources" (is (=? {:resources [{:error string?}]} (read-resource/read-resource {:uris [(str "metabase://transform/" transform-id "/fields")]})))) - (testing "returns error for unknown transform" (is (=? {:resources [{:error string?}]} (read-resource/read-resource {:uris ["metabase://transform/99999"]})))))))) +;; ===== Permission coverage — every branch ===== +;; +;; Two patterns: +;; 1. Single-entity reads (and their sub-resources) call `api/read-check` first, which +;; throws when `mi/can-read?` returns false. The handler should error out. +;; 2. List handlers run `(filter mi/can-read?)` over their results. Items the user +;; can't read should silently disappear from the output. +;; +;; Strategy: stub `mi/can-read?` and assert the corresponding response shape. + +(defn- error? + "Whether a read-resource response carries an error for its first URI." + [result] + (some? (-> result :resources first :error))) + +(deftest read-check-throws-on-missing-perm-test + (testing "every single-entity URI errors when user lacks read perms on the entity" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {table-id :id} {:db_id db-id :active true :schema "PUBLIC"} + :model/Card {model-id :id} {:type :model :database_id db-id} + :model/Card {q-id :id} {:type :question :database_id db-id} + :model/Card {metric-id :id} {:type :metric :database_id db-id} + :model/Collection {coll-id :id} {} + :model/Dashboard {dash-id :id} {}] + (let [uris [;; Database family — api/read-check on the DB + (str "metabase://database/" db-id) + (str "metabase://database/" db-id "/tables") + (str "metabase://database/" db-id "/models") + (str "metabase://database/" db-id "/schemas") + (str "metabase://database/" db-id "/schemas/PUBLIC/tables") + ;; Collection family — api/read-check on the Collection + (str "metabase://collection/" coll-id) + (str "metabase://collection/" coll-id "/items") + (str "metabase://collection/" coll-id "/subcollections") + ;; Table family — api/read-check via metabot.tools.util/get-table + (str "metabase://table/" table-id) + (str "metabase://table/" table-id "/fields") + (str "metabase://table/" table-id "/fields/42") + (str "metabase://table/" table-id "/derived") + ;; Card (model) — api/read-check via get-card + (str "metabase://model/" model-id) + (str "metabase://model/" model-id "/fields") + (str "metabase://model/" model-id "/fields/42") + (str "metabase://model/" model-id "/sources") + ;; Card (question) + (str "metabase://question/" q-id) + (str "metabase://question/" q-id "/fields") + (str "metabase://question/" q-id "/fields/42") + (str "metabase://question/" q-id "/sources") + ;; Metric — api/read-check via get-card + (str "metabase://metric/" metric-id) + (str "metabase://metric/" metric-id "/dimensions") + (str "metabase://metric/" metric-id "/dimensions/42") + ;; Dashboard — api/read-check via get-dashboard-details + (str "metabase://dashboard/" dash-id) + (str "metabase://dashboard/" dash-id "/items")]] + (with-redefs [mi/can-read? (constantly false)] + (doseq [uri uris] + (testing uri + (is (error? (read-resource/read-resource {:uris [uri]})) + (str uri " should return an :error response when user lacks read perms")))))))))) + +(deftest read-check-throws-on-missing-perm-transform-test + (testing "transform URIs error when the transform itself is unreadable" + (mt/with-premium-features #{:transforms} + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Transform {transform-id :id} + {:name "Permission test transform" + :source {:type "query" + :query (lib/native-query (mt/metadata-provider) "SELECT 1")}}] + (with-redefs [mi/can-read? (constantly false)] + (doseq [uri [(str "metabase://transform/" transform-id) + (str "metabase://transform/" transform-id "/sources") + (str "metabase://transform/" transform-id "/target")]] + (testing uri + (is (error? (read-resource/read-resource {:uris [uri]})) + (str uri " should return an :error response when user can't read transform")))))))))) + +(deftest list-filters-databases-by-can-read-test + (testing "metabase://databases hides DBs the user can't read" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database _ {:name "VISIBLE-DB"} + :model/Database {hidden-id :id} {:name "HIDDEN-DB"}] + (let [orig mi/can-read?] + (with-redefs [mi/can-read? (fn + ([instance] + (if (= hidden-id (:id instance)) false (orig instance))) + ([model id] + (if (= hidden-id id) false (orig model id))))] + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://databases"]})] + (is (str/includes? output "VISIBLE-DB")) + (is (not (str/includes? output "HIDDEN-DB")) + "unreadable database must not appear in the list")))))))) + +(deftest list-filters-collections-by-can-read-test + (testing "metabase://collections hides collections the user can't read" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection _ {:name "VISIBLE-COLL" :location "/"} + :model/Collection {hidden-id :id} {:name "HIDDEN-COLL" :location "/"}] + (let [orig mi/can-read?] + (with-redefs [mi/can-read? (fn + ([instance] + (if (= hidden-id (:id instance)) false (orig instance))) + ([model id] + (if (= hidden-id id) false (orig model id))))] + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://collections"]})] + (is (str/includes? output "VISIBLE-COLL")) + (is (not (str/includes? output "HIDDEN-COLL")) + "unreadable collection must not appear in the list")))))))) + +(deftest list-filters-collection-items-by-can-read-test + (testing "metabase://collection/{id}/items hides individual items the user can't read" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection {coll-id :id} {:name "Mixed Coll" :location "/"} + :model/Card _ {:name "VISIBLE-CARD" :collection_id coll-id} + :model/Card {hidden-card :id} {:name "HIDDEN-CARD" :collection_id coll-id} + :model/Dashboard _ {:name "VISIBLE-DASH" :collection_id coll-id} + :model/Dashboard {hidden-dash :id} {:name "HIDDEN-DASH" :collection_id coll-id}] + (let [orig mi/can-read? + hidden-cards #{hidden-card} + hidden-dashes #{hidden-dash}] + (with-redefs [mi/can-read? + (fn + ([instance] + (cond + (and (= :model/Card (t2/model instance)) (hidden-cards (:id instance))) false + (and (= :model/Dashboard (t2/model instance)) (hidden-dashes (:id instance))) false + :else (orig instance))) + ([model id] (orig model id)))] + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://collection/" coll-id "/items")]})] + (is (str/includes? output "VISIBLE-CARD")) + (is (str/includes? output "VISIBLE-DASH")) + (is (not (str/includes? output "HIDDEN-CARD")) + "unreadable card must not appear in collection items") + (is (not (str/includes? output "HIDDEN-DASH")) + "unreadable dashboard must not appear in collection items")))))))) + +(deftest list-filters-database-tables-by-can-read-test + (testing "metabase://database/{id}/tables hides tables the user can't read" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table _ {:db_id db-id :name "VISIBLE-TBL" :active true} + :model/Table {hidden-tbl :id} {:db_id db-id :name "HIDDEN-TBL" :active true}] + (let [orig mi/can-read?] + (with-redefs [mi/can-read? (fn + ([instance] + (if (and (= :model/Table (t2/model instance)) + (= hidden-tbl (:id instance))) + false + (orig instance))) + ([model id] (orig model id)))] + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://database/" db-id "/tables")]})] + (is (str/includes? output "VISIBLE-TBL")) + (is (not (str/includes? output "HIDDEN-TBL")) + "unreadable table must not appear in the database tables list")))))))) + +(deftest list-filters-dashboard-items-by-can-read-test + (testing "metabase://dashboard/{id}/items hides cards the user can't read" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Dashboard {dash-id :id} {} + :model/Card {visible-card :id} {:name "VISIBLE-DASHCARD"} + :model/Card {hidden-card :id} {:name "HIDDEN-DASHCARD"} + :model/DashboardCard _ {:dashboard_id dash-id :card_id visible-card} + :model/DashboardCard _ {:dashboard_id dash-id :card_id hidden-card}] + (let [orig mi/can-read?] + (with-redefs [mi/can-read? (fn + ([instance] + (if (and (= :model/Card (t2/model instance)) + (= hidden-card (:id instance))) + false + (orig instance))) + ([model id] (orig model id)))] + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://dashboard/" dash-id "/items")]})] + (is (str/includes? output "VISIBLE-DASHCARD")) + (is (not (str/includes? output "HIDDEN-DASHCARD")) + "unreadable card must not appear in dashboard items")))))))) + +(deftest read-transform-target-authorization-test + (testing "fetch-transform-target gates the target table by mi/can-read?" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {target-id :id :as target-table} + {:db_id db-id :name "TARGET-TABLE" :schema "PUBLIC" :active true}] + (let [stub-transform {:id 999 + :name "Stub Transform" + :source_database_id db-id + :target_db_id db-id + :table target-table}] + (testing "when user CAN read the target, it appears in the output" + (with-redefs [transforms.core/get-transform (constantly stub-transform)] + (let [{:keys [output]} (read-resource/read-resource + {:uris ["metabase://transform/999/target"]})] + (is (str/includes? output "TARGET-TABLE") + "target table name should appear when user has read perms") + (is (str/includes? output (str "uri=\"metabase://table/" target-id "\"")) + "target table URI should appear when user has read perms")))) + (testing "when user CANNOT read the target, it's filtered out" + (with-redefs [transforms.core/get-transform (constantly stub-transform) + mi/can-read? (constantly false)] + (let [{:keys [output]} (read-resource/read-resource + {:uris ["metabase://transform/999/target"]})] + (is (not (str/includes? output "TARGET-TABLE")) + "target table name must NOT appear when user lacks read perms") + (is (not (str/includes? output (str "uri=\"metabase://table/" target-id "\""))) + "target table URI must NOT appear when user lacks read perms") + ;; The target *database* URI is still surfaced — that's intentional, the + ;; URI carries no extra metadata and any read_resource call on it will + ;; enforce its own auth. + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\"")) + "target database URI is informational and remains visible"))))))))) + +(deftest read-databases-list-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {:name "Test DB"}] + (testing "metabase://databases returns the database with its drill-in URI" + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://databases"]})] + (is (str/includes? output "Test DB")) + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\"")))))))) + +(deftest read-database-tables-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {t-id :id} {:db_id db-id :name "ORDERS" :active true}] + (testing "metabase://database/{id}/tables lists tables with drill-in URIs" + (let [{:keys [output]} (read-resource/read-resource {:uris [(str "metabase://database/" db-id "/tables")]})] + (is (str/includes? output "ORDERS")) + (is (str/includes? output (str "uri=\"metabase://table/" t-id "\"")))))))) + +(deftest read-collections-and-collection-items-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection {coll-id :id} {:name "Marketing" :location "/"} + :model/Card {card-id :id} {:name "Sales report" :collection_id coll-id}] + (testing "metabase://collections lists root collections (excluding trash)" + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://collections"]})] + (is (str/includes? output "Marketing")))) + (testing "metabase://collection/{id}/items lists members with drill-in URIs" + (let [{:keys [output]} (read-resource/read-resource {:uris [(str "metabase://collection/" coll-id "/items")]})] + (is (str/includes? output "Sales report")) + (is (str/includes? output (str "uri=\"metabase://question/" card-id "\"")))))))) + +(deftest read-table-derived-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {table-id :id} {:db_id db-id} + :model/Card {card-id :id} + {:name "Derived" + :type :model + :database_id db-id + :table_id table-id}] + (testing "metabase://table/{id}/derived returns cards built on the table" + (let [{:keys [output]} (read-resource/read-resource {:uris [(str "metabase://table/" table-id "/derived")]})] + (is (str/includes? output "Derived")) + (is (str/includes? output (str "uri=\"metabase://model/" card-id "\"")))))))) + +(deftest read-table-derived-narrows-transforms-by-source-db-test + (testing "transform candidates are SQL-filtered by source_database_id (no full Transform table scan)" + (mt/with-premium-features #{:transforms} + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db1 :id} {:name "DB1"} + :model/Database {db2 :id} {:name "DB2"} + :model/Table {tbl1-id :id} {:db_id db1} + :model/Transform {tx-other-db :id} + {:name "Other-DB Transform" + :source_database_id db2 + :source {:type "query" + :query (lib/native-query (mt/metadata-provider) "SELECT 1")}}] + (testing "/derived for a table in db1 must exclude transforms whose source is in db2" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://table/" tbl1-id "/derived")]})] + (is (not (str/includes? output "Other-DB Transform")) + "transforms not sourced from this table's database must not appear") + (is (not (str/includes? output (str "uri=\"metabase://transform/" tx-other-db "\""))))))))))) + +(deftest read-card-sources-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {table-id :id} {:db_id db-id} + :model/Card {card-id :id} {:type :model :database_id db-id :table_id table-id}] + (testing "metabase://model/{id}/sources returns the FK-resolved sources" + (let [{:keys [output]} (read-resource/read-resource {:uris [(str "metabase://model/" card-id "/sources")]})] + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\"")) + "should include the database URI") + (is (str/includes? output (str "uri=\"metabase://table/" table-id "\"")) + "should include the source-table URI")))))) + +(deftest read-card-sources-source-card-type-test + (testing "source-card resolution preserves the card type — metric must not collapse to question" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {table-id :id} {:db_id db-id} + :model/Card {metric-id :id} {:type :metric + :database_id db-id + :table_id table-id} + :model/Card {model-id :id} {:type :model + :database_id db-id + :table_id table-id} + :model/Card {q-id :id} {:type :question + :database_id db-id + :table_id table-id + :source_card_id metric-id} + :model/Card {q-from-model-id :id} {:type :question + :database_id db-id + :table_id table-id + :source_card_id model-id}] + (testing "source_card_id pointing at a :metric emits a metric URI" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://question/" q-id "/sources")]})] + (is (str/includes? output (str "uri=\"metabase://metric/" metric-id "\"")) + "should resolve source-card of type :metric to a metric URI, not question") + (is (not (str/includes? output (str "uri=\"metabase://question/" metric-id "\""))) + "must NOT collapse the metric source-card to a question URI"))) + (testing "source_card_id pointing at a :model still emits a model URI (regression)" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://question/" q-from-model-id "/sources")]})] + (is (str/includes? output (str "uri=\"metabase://model/" model-id "\""))))))))) + +(deftest read-dashboard-items-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Dashboard {dash-id :id} {} + :model/Card {card-id :id} {:name "Dash card"} + :model/DashboardCard _ {:dashboard_id dash-id :card_id card-id}] + (testing "metabase://dashboard/{id}/items returns cards on the dashboard" + (let [{:keys [output]} (read-resource/read-resource {:uris [(str "metabase://dashboard/" dash-id "/items")]})] + (is (str/includes? output "Dash card")) + (is (str/includes? output (str "uri=\"metabase://question/" card-id "\"")))))))) + +(deftest read-user-recents-test + (mt/with-current-user (mt/user->id :crowberto) + (testing "metabase://user/recent-items returns a list shape (possibly empty)" + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://user/recent-items"]})] + (is (str/includes? output "id :crowberto) + (let [{:keys [output]} (read-resource/read-resource {:uris ["metabase://databases"]})] + (is (str/includes? output "")) (is (str/includes? formatted "")))) - (testing "formats resources with errors" (let [resources [{:uri "metabase://table/123" :error "Table not found"}] formatted (#'read-resource/format-resources resources)] (is (str/includes? formatted "**Error:** Table not found"))))) +;; ===== Behavioral tests for patterns where the dispatch contract isn't enough ===== + +(deftest read-database-detail-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {:name "Detail DB" :engine :h2}] + (testing "metabase://database/{id} returns single-entity output with engine + uri" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://database/" db-id)]})] + (is (str/includes? output "Detail DB")) + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\""))) + (is (str/includes? output "engine=\"h2\""))))))) + +(deftest read-database-models-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Card {model-id :id} {:type :model :database_id db-id :name "M-One"} + :model/Card _ {:type :question :database_id db-id :name "Q-Skip"}] + (testing "metabase://database/{id}/models lists models only (not questions)" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://database/" db-id "/models")]})] + (is (str/includes? output "M-One")) + (is (str/includes? output (str "uri=\"metabase://model/" model-id "\""))) + (is (not (str/includes? output "Q-Skip")))))))) + +(deftest read-database-schemas-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table _ {:db_id db-id :schema "PUBLIC" :name "t1" :active true} + :model/Table _ {:db_id db-id :schema "PRIVATE" :name "t2" :active true}] + (testing "metabase://database/{id}/schemas emits a drill-in URI per schema" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://database/" db-id "/schemas")]})] + (is (str/includes? output "PUBLIC")) + (is (str/includes? output "PRIVATE")) + (is (str/includes? output (str "uri=\"metabase://database/" db-id "/schemas/PUBLIC/tables\""))) + (is (str/includes? output (str "uri=\"metabase://database/" db-id "/schemas/PRIVATE/tables\"")))))))) + +(deftest read-database-schema-tables-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {pub-id :id} {:db_id db-id :schema "PUBLIC" :name "PUB-TABLE" :active true} + :model/Table _ {:db_id db-id :schema "PRIVATE" :name "PRIV-TABLE" :active true}] + (testing "metabase://database/{id}/schemas/{name}/tables filters by schema" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://database/" db-id "/schemas/PUBLIC/tables")]})] + (is (str/includes? output "PUB-TABLE")) + (is (str/includes? output (str "uri=\"metabase://table/" pub-id "\""))) + (is (not (str/includes? output "PRIV-TABLE")))))))) + +(deftest read-database-schema-tables-with-slash-in-schema-name-test + (testing "schema names containing '/' (which Postgres/Snowflake/etc. allow) survive URI round-trip" + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Table {weird-id :id} {:db_id db-id :schema "weird/name" :name "WEIRD-TABLE" :active true} + :model/Table _ {:db_id db-id :schema "other" :name "OTHER-TABLE" :active true}] + (let [emitted-uri (llm-rep/metabase-uri :database db-id "schemas" "weird/name" "tables")] + (testing "the URI builder emits an encoded segment" + (is (str/includes? emitted-uri "weird%2Fname"))) + (testing "the encoded URI dispatches and filters to the right schema" + (let [{:keys [output]} (read-resource/read-resource {:uris [emitted-uri]})] + (is (str/includes? output "WEIRD-TABLE")) + (is (str/includes? output (str "uri=\"metabase://table/" weird-id "\""))) + (is (not (str/includes? output "OTHER-TABLE")))))))))) + +(deftest read-collection-detail-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection {coll-id :id} {:name "Detail Coll" :location "/"}] + (testing "metabase://collection/{id} returns single-entity output with name + uri" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://collection/" coll-id)]})] + (is (str/includes? output "Detail Coll")) + (is (str/includes? output (str "uri=\"metabase://collection/" coll-id "\"")))))))) + +(deftest read-collection-subcollections-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection {parent-id :id} {:name "Parent" :location "/"} + :model/Collection {child-id :id} {:name "Child" :location (str "/" parent-id "/")}] + (testing "metabase://collection/{id}/subcollections lists direct children only" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://collection/" parent-id "/subcollections")]})] + (is (str/includes? output "Child")) + (is (str/includes? output (str "uri=\"metabase://collection/" child-id "\""))) + (is (not (str/includes? output "Parent")))))))) + +(deftest read-collections-tree-test + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-temp [:model/Collection {parent-id :id} {:name "P" :location "/"} + :model/Collection _ {:name "C" :location (str "/" parent-id "/")}] + (testing "metabase://collections?tree=true returns all collections with full path strings" + (let [{:keys [output]} (read-resource/read-resource + {:uris ["metabase://collections?tree=true"]})] + (is (str/includes? output "id :crowberto) + (mt/with-temp [:model/Database {db-id :id} {} + :model/Card {q-id :id} {:type :question :database_id db-id :name "Q-card"} + :model/Card {m-id :id} {:type :model :database_id db-id :name "M-card"}] + (testing "metabase://question/{id}/sources discriminates from model" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://question/" q-id "/sources")]})] + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\""))))) + (testing "metabase://model/{id}/sources for a model card" + (let [{:keys [output]} (read-resource/read-resource + {:uris [(str "metabase://model/" m-id "/sources")]})] + (is (str/includes? output (str "uri=\"metabase://database/" db-id "\"")))))))) + (deftest read-question-resource-test (let [mp (mt/metadata-provider) query (as-> (lib/query mp (lib.metadata/table mp (mt/id :products))) $ @@ -172,7 +734,6 @@ metadata (-> query qp/process-query :data :results_metadata :columns)] - (mt/with-temp [:model/Card {question-id :id} {:name "My fav card" :dataset_query query diff --git a/test/metabase/metabot/tools/search_test.clj b/test/metabase/metabot/tools/search_test.clj index 62a0d603fbf1..d8546945183e 100644 --- a/test/metabase/metabot/tools/search_test.clj +++ b/test/metabase/metabot/tools/search_test.clj @@ -24,7 +24,6 @@ (is (= 1 (-> result first :id))) (is (= 2 (-> result second :id))) (is (= 3 (-> result last :id))))) - (testing "RRF with multiple lists - no overlap" (let [list1 [{:id 1 :model "card" :name "Card 1"} {:id 2 :model "dashboard" :name "Dashboard 1"}] @@ -33,7 +32,6 @@ result (#'search/reciprocal-rank-fusion [list1 list2])] (is (= 4 (count result))) (is (every? #(contains? #{1 2 3 4} (:id %)) result)))) - (testing "RRF with overlapping results - should boost common items" (let [list1 [{:id 1 :model "card" :name "Revenue Report"} {:id 2 :model "dashboard" :name "Sales Dashboard"} @@ -47,7 +45,6 @@ (let [top-two-ids (set (map :id (take 2 result)))] (is (contains? top-two-ids 1)) (is (contains? top-two-ids 2))))) - (testing "RRF with identical items at different positions" (let [list1 [{:id 1 :model "card" :name "First"} {:id 2 :model "dashboard" :name "Second"} @@ -62,18 +59,15 @@ (is (= 3 (count result))) ;; Item 2 appears first in list3, second in list1 and list2, so should rank highest (is (= 2 (-> result first :id))))) - (testing "RRF with empty lists" (let [list1 [] list2 [{:id 1 :model "card" :name "Card 1"}] result (#'search/reciprocal-rank-fusion [list1 list2])] (is (= 1 (count result))) (is (= 1 (-> result first :id))))) - (testing "RRF with all empty lists" (let [result (#'search/reciprocal-rank-fusion [[] [] []])] (is (empty? result)))) - (testing "RRF score calculation correctness" ;; Test that the RRF formula 1/(k+r) where k=60 is correctly applied (let [list1 [{:id 1 :model "card" :name "Rank 1"}] ; rank=1, score=1/61 @@ -88,7 +82,6 @@ ;; So item 1 should rank higher than item 2 (is (= 1 (:id first-item))) (is (= 2 (:id second-item))))) - (testing "RRF preserves item data" (let [complex-item {:id 42 :model "dataset" @@ -100,7 +93,6 @@ result (#'search/reciprocal-rank-fusion [[complex-item]])] (is (= 1 (count result))) (is (= complex-item (first result))))) - (testing "RRF with many lists" (let [lists (for [i (range 5)] [{:id (inc i) :model "card" :name (str "Card " (inc i))} @@ -131,7 +123,6 @@ :updated_at "2024-01-01" :created_at "2024-01-01"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "model (dataset) result postprocessing" (let [result {:model "dataset" :id 2 @@ -152,7 +143,6 @@ :updated_at "2024-01-02" :created_at "2024-01-02"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "transform result postprocessing" (let [result {:model "transform" :id 3 @@ -169,7 +159,6 @@ :updated_at "2024-01-03" :created_at "2024-01-03"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "dashboard result postprocessing" (let [result {:model "dashboard" :id 3 @@ -188,7 +177,6 @@ :updated_at "2024-01-03" :created_at "2024-01-03"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "question (card) result postprocessing with moderated_status" (let [result {:model "card" :id 4 @@ -208,7 +196,6 @@ :updated_at "2024-01-04" :created_at "2024-01-04"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "metric result postprocessing" (let [result {:model "metric" :id 5 @@ -227,7 +214,6 @@ :updated_at "2024-01-05" :created_at "2024-01-05"}] (is (= expected (#'search/postprocess-search-result result))))) - (testing "database result postprocessing" (let [result {:model "database" :id 6 @@ -255,7 +241,6 @@ (search/search {:term-queries ["test"] :entity-types ["card"] :search-native-query true}))) - (testing ":search-native-query is not included in context when nil or false" (with-redefs [search-core/search (fn [context] (is (not (contains? context :search-native-query))) @@ -283,7 +268,6 @@ (is (not (contains? @captured "dashboard"))) (is (not (contains? @captured "transform"))) (is (not (contains? @captured "database"))))) - (testing "sql-search-tool with no entity_types searches only table/model" (let [captured (atom nil)] (mt/with-dynamic-fn-redefs [search-core/search (fn [context] @@ -291,7 +275,6 @@ {:data []})] (search/sql-search-tool {:keyword_queries ["x"] :database_id 1})) (is (= #{"table" "dataset"} @captured)))) - (testing "agent-supplied entity_types narrow the default allowed set" (let [captured (atom nil)] (mt/with-dynamic-fn-redefs [search-core/search (fn [context] @@ -313,7 +296,6 @@ {:data []})] (search/search-tool {:keyword_queries ["x"]})) (is (= 10 @captured)))) - (testing "explicit limit is honored" (let [captured (atom nil)] (mt/with-dynamic-fn-redefs [search-core/search (fn [context] @@ -321,11 +303,9 @@ {:data []})] (search/search-tool {:keyword_queries ["x"] :limit 25})) (is (= 25 @captured)))) - (testing "limit above 50 is rejected by schema validation" (is (thrown? Exception (search/search-tool {:keyword_queries ["x"] :limit 75})))) - (testing "limit below 1 is rejected by schema validation" (is (thrown? Exception (search/search-tool {:keyword_queries ["x"] :limit 0})))))))) @@ -374,7 +354,6 @@ analytics-dash (u/seek #(= dash-2-id (:id %)) test-results)] (is (= "Finance team collection" (get-in finance-dash [:collection :description]))) (is (= "Analytics collection" (get-in analytics-dash [:collection :description]))))) - (testing "handles nil collection descriptions" (let [no-desc-dash (u/seek #(= dash-3-id (:id %)) test-results)] (is (nil? (get-in no-desc-dash [:collection :description]))) diff --git a/test/metabase/metabot/tools/shared/llm_representations_test.clj b/test/metabase/metabot/tools/shared/llm_representations_test.clj index 89ed138f6251..0d2d7f44faad 100644 --- a/test/metabase/metabot/tools/shared/llm_representations_test.clj +++ b/test/metabase/metabot/tools/shared/llm_representations_test.clj @@ -12,7 +12,6 @@ (is (= """ (#'llm-rep/escape-xml "\""))) (is (= "<script>alert("xss")</script>" (#'llm-rep/escape-xml "")))) - (testing "escape-xml handles nil" (is (nil? (#'llm-rep/escape-xml nil))))) @@ -34,7 +33,6 @@ (is (str/includes? xml "database_type=\"INTEGER\"")) (is (str/includes? xml "## Description")) (is (str/includes? xml "The user identifier")))) - (testing "handles missing optional attributes with defaults" (let [field {:field_id "f1" :name "test"} xml (llm-rep/field->xml field)] @@ -50,7 +48,6 @@ (is (str/includes? xml "name=\"Finance\"")) (is (str/includes? xml "authority_level=\"official\"")) (is (str/includes? xml "Finance reports")))) - (testing "uses default name for nil" (let [collection {:name nil} xml (llm-rep/collection->xml collection)] @@ -78,7 +75,6 @@ (is (str/includes? xml "The metric is stored in the following collection")) (is (str/includes? xml "Default Time Dimension Field: created_at")) (is (str/ends-with? (str/trim xml) "")))) - (testing "handles metric without dimensions" (let [metric {:id 1 :name "Test" :verified false} xml (llm-rep/metric->xml metric)] @@ -103,7 +99,6 @@ (is (str/includes? xml "")) (is (str/includes? xml ":source-table 5")) (is (str/ends-with? (str/trim xml) "")))) - (testing "handles measure without description or definition" (let [measure {:id 2 :name "count_orders"} xml (llm-rep/measure->xml measure)] @@ -113,7 +108,6 @@ (is (not (str/includes? xml ""))) (is (not (str/includes? xml "Definition:"))) (is (str/ends-with? (str/trim xml) "")))) - (testing "uses name as display_name fallback" (let [measure {:id 3 :name "avg_price" :display-name nil} xml (llm-rep/measure->xml measure)] @@ -137,7 +131,6 @@ (is (str/includes? xml "")) (is (str/includes? xml ":source-table 5")) (is (str/ends-with? (str/trim xml) "")))) - (testing "handles segment without description or definition-description" (let [segment {:id 2 :name "new_users"} xml (llm-rep/segment->xml segment)] @@ -146,7 +139,6 @@ (is (not (str/includes? xml ""))) (is (not (str/includes? xml "Definition:"))) (is (str/ends-with? (str/trim xml) "")))) - (testing "uses name as display_name fallback" (let [segment {:id 3 :name "q4_orders" :display-name nil} xml (llm-rep/segment->xml segment)] @@ -179,7 +171,6 @@ (is (str/includes? xml "")))) - (testing "includes measures and segments when present" (let [table {:id 10 :name "order_facts" @@ -199,7 +190,6 @@ (is (str/includes? xml "### Segments (Pre-defined Filter Conditions)")) (is (str/includes? xml "xml table)] @@ -230,7 +220,6 @@ (is (str/includes? xml "metabase://model/5/fields/{field_id}")) ;; Python closes with (is (str/ends-with? (str/trim xml) "")))) - (testing "includes measures and segments when present" (let [model {:id 5 :name "Sales Model" @@ -249,7 +238,6 @@ (is (str/includes? xml "### Segments (Pre-defined Filter Conditions)")) (is (str/includes? xml "xml model)] @@ -274,7 +262,6 @@ (is (str/includes? xml "### Result Columns")) (is (str/includes? xml "### Result Rows")) (is (str/ends-with? (str/trim xml) "")))) - (testing "handles query with no result" (let [query {:query-type :notebook :query-id "m1" :database_id 1} xml (llm-rep/query->xml query)] @@ -293,7 +280,6 @@ (is (str/includes? xml "query-id=\"q1\"")) (is (str/includes? xml "metabase://chart/ch-abc-123")) (is (str/ends-with? (str/trim xml) "")))) - (testing "handles nil chart-type" (let [chart {:chart-id "c1" :query-id "q1" :chart-type nil} xml (llm-rep/chart->xml chart)] @@ -391,7 +377,6 @@ (is (str/includes? xml "Total revenue calculation")) (is (str/includes? xml "Collection: Finance")) (is (str/ends-with? (str/trim xml) "")))) - (testing "table search result includes database_id, database_engine, and fully_qualified_name" (let [result {:id 133 :type "table" @@ -406,7 +391,6 @@ (is (str/includes? xml "database_id=\"2\"")) (is (str/includes? xml "database_engine=\"postgres\"")) (is (str/includes? xml "fully_qualified_name=\"shopify_data.order\"")))) - (testing "table search result without schema omits schema prefix in fqn" (let [result {:id 10 :type "table" @@ -416,7 +400,6 @@ xml (llm-rep/search-result->xml result)] (is (str/includes? xml "fully_qualified_name=\"users\"")) (is (str/includes? xml "database_engine=\"h2\"")))) - (testing "model search result includes database_id, database_engine, and fully_qualified_name" (let [result {:id 5 :type "model" @@ -429,7 +412,6 @@ (is (str/includes? xml "database_id=\"1\"")) (is (str/includes? xml "database_engine=\"postgres\"")) (is (str/includes? xml "fully_qualified_name=\"{#5}-sales-model\"")))) - (testing "non-table/model search results omit table-specific attributes" (let [result {:id 50 :type :dashboard @@ -439,7 +421,6 @@ (is (not (str/includes? xml "fully_qualified_name"))) (is (not (str/includes? xml "database_id"))) (is (not (str/includes? xml "database_engine"))))) - (testing "uses correct tag names for different types" (is (str/starts-with? (llm-rep/search-result->xml {:id 1 :type :table :name "t"}) " tag @@ -459,7 +440,6 @@ (is (str/includes? xml "")))) - (testing "handles empty results" (let [xml (llm-rep/search-results->xml [])] (is (str/includes? xml "")) @@ -476,7 +456,6 @@ (is (str/includes? xml "| US |")) (is (str/includes? xml "**Field Statistics (SAMPLE-BASED)**")) (is (str/includes? xml "sample_distinct_count")))) - (testing "handles empty field values" (let [metadata {:field_values []} xml (llm-rep/field-values-metadata->xml metadata)] @@ -489,7 +468,6 @@ xml (llm-rep/field-metadata->xml metadata)] (is (str/includes? xml "")) (is (str/includes? xml "**Sample Values")))) - (testing "handles nil value_metadata" (let [metadata {:field_id "f1" :value_metadata nil} xml (llm-rep/field-metadata->xml metadata)] @@ -508,12 +486,10 @@ ;; Uses to match Python (is (str/includes? xml "")) (is (str/includes? xml "")))) - (testing "handles no metadata" (let [result {:metrics [] :tables [] :models []} xml (llm-rep/get-metadata-result->xml result)] (is (str/includes? xml "No metadata was returned")))) - (testing "includes errors" (let [result {:metrics [] :tables [] :models [] :errors ["Error 1"]} xml (llm-rep/get-metadata-result->xml result)] @@ -530,7 +506,6 @@ (is (str/starts-with? (llm-rep/entity->xml {:type :dashboard :id 1 :name "d"}) "xml {:type :user :id 1 :name "u" :email "u@test.com"}) "xml {:type :collection :name "c"}) "xml {:type :unknown :data "test"})] (is (str/includes? result ":type"))))) diff --git a/test/metabase/metabot/tools/sql/tools_test.clj b/test/metabase/metabot/tools/sql/tools_test.clj index b25eb39e199d..cfdf1b8a108c 100644 --- a/test/metabase/metabot/tools/sql/tools_test.clj +++ b/test/metabase/metabot/tools/sql/tools_test.clj @@ -30,7 +30,6 @@ :new_string "id = 2"}]})] (is (= query-id (:query-id result))) (is (= "SELECT * FROM users WHERE id = 2" (:query-content result))))) - (testing "edit with JSON-parsed MBQL 5 query (string enum values)" (let [original-sql "SELECT * FROM users WHERE id = 1" query-id "q-mbql5" @@ -47,7 +46,6 @@ :new_string "id = 2"}]})] (is (= query-id (:query-id result))) (is (= "SELECT * FROM users WHERE id = 2" (:query-content result))))) - (testing "replace-all edit" (let [mp (mt/metadata-provider) original-sql "SELECT id, id FROM users WHERE id = 1" @@ -64,7 +62,6 @@ :replace_all true}]})] (is (= "SELECT user_id, user_id FROM users WHERE user_id = 1" (:query-content result))))) - (testing "rejects ambiguous edits" (let [mp (mt/metadata-provider) original-sql "SELECT id, id FROM users" @@ -100,7 +97,6 @@ :sql new-sql})] (is (= new-sql (:query-content result))) (is (= db-id (:database result))))) - (testing "replace with JSON-parsed MBQL 5 query (string enum values)" (let [original-sql "SELECT * FROM users" new-sql "SELECT id, name FROM customers" @@ -116,7 +112,6 @@ :sql new-sql})] (is (= new-sql (:query-content result))) (is (= db-id (:database result))))) - (testing "replaces SQL and updates name/description" (let [mp (mt/metadata-provider) query-id "q6" diff --git a/test/metabase/metabot/tools/subscriptions_test.clj b/test/metabase/metabot/tools/subscriptions_test.clj index c4c05738b65c..ccb5be38b741 100644 --- a/test/metabase/metabot/tools/subscriptions_test.clj +++ b/test/metabase/metabot/tools/subscriptions_test.clj @@ -12,7 +12,6 @@ (let [m (meta #'agent-subscriptions/create-dashboard-subscription-tool)] (testing "tool has correct :tool-name" (is (= "create_dashboard_subscription" (:tool-name m)))) - (testing "tool var has expected metadata" (is (some? (:schema m))) (is (string? (:doc m)))))) diff --git a/test/metabase/metabot/tools/todo_test.clj b/test/metabase/metabot/tools/todo_test.clj index a78d7ee56b5f..110044cb35d1 100644 --- a/test/metabase/metabot/tools/todo_test.clj +++ b/test/metabase/metabot/tools/todo_test.clj @@ -15,7 +15,6 @@ (is (contains? result :instructions)) ;; Check memory was updated (is (= todos (get-in @memory-atom [:state :todos]))))) - (testing "todo-write returns data-parts with todo_list type" (let [memory-atom (atom {:state {}}) todos [{:id "1" :content "Task" :status "pending" :priority "low"}] @@ -25,7 +24,6 @@ (is (= "todo_list" (:data-type data-part))) (is (= 1 (:version data-part))) (is (= todos (:data data-part))))) - (testing "todo-write rejects invalid status" (let [memory-atom (atom {:state {}}) todos [{:id "1" :content "Task" :status "invalid_status" :priority "high"}]] @@ -33,7 +31,6 @@ clojure.lang.ExceptionInfo #"Invalid todo status" (todo/todo-write {:todos todos :memory-atom memory-atom}))))) - (testing "todo-write rejects invalid priority" (let [memory-atom (atom {:state {}}) todos [{:id "1" :content "Task" :status "pending" :priority "critical"}]] @@ -41,7 +38,6 @@ clojure.lang.ExceptionInfo #"Invalid todo priority" (todo/todo-write {:todos todos :memory-atom memory-atom}))))) - (testing "todo-write rejects missing id" (let [memory-atom (atom {:state {}}) todos [{:content "Task" :status "pending" :priority "high"}]] @@ -49,7 +45,6 @@ clojure.lang.ExceptionInfo #"missing required 'id' field" (todo/todo-write {:todos todos :memory-atom memory-atom}))))) - (testing "todo-write rejects missing content" (let [memory-atom (atom {:state {}}) todos [{:id "1" :status "pending" :priority "high"}]] @@ -57,14 +52,12 @@ clojure.lang.ExceptionInfo #"missing required 'content' field" (todo/todo-write {:todos todos :memory-atom memory-atom}))))) - (testing "todo-write accepts all valid statuses" (let [memory-atom (atom {:state {}})] (doseq [status ["pending" "in_progress" "completed" "cancelled"]] (let [todos [{:id "1" :content "Task" :status status :priority "medium"}] result (todo/todo-write {:todos todos :memory-atom memory-atom})] (is (some? (:structured-output result))))))) - (testing "todo-write accepts all valid priorities" (let [memory-atom (atom {:state {}})] (doseq [priority ["high" "medium" "low"]] @@ -80,7 +73,6 @@ (is (contains? result :structured-output)) (is (= [] (get-in result [:structured-output :todos]))) (is (= 0 (get-in result [:structured-output :todo_count]))))) - (testing "todo-read returns stored todos" (let [todos [{:id "1" :content "Task 1" :status "pending" :priority "high"} {:id "2" :content "Task 2" :status "completed" :priority "low"}] @@ -88,7 +80,6 @@ result (todo/todo-read {:memory-atom memory-atom})] (is (= todos (get-in result [:structured-output :todos]))) (is (= 2 (get-in result [:structured-output :todo_count]))))) - (testing "todo-read includes instructions for LLM" (let [memory-atom (atom {:state {:todos [{:id "1" :content "Task" :status "pending" :priority "medium"}]}}) result (todo/todo-read {:memory-atom memory-atom})] diff --git a/test/metabase/metabot/tools/transforms/write_test.clj b/test/metabase/metabot/tools/transforms/write_test.clj index 985977abf66b..5a6f1ceed704 100644 --- a/test/metabase/metabot/tools/transforms/write_test.clj +++ b/test/metabase/metabot/tools/transforms/write_test.clj @@ -19,7 +19,6 @@ (is (= "SELECT * FROM users" (some-> (get-in result [:structured-output :transform :source :query]) lib/raw-native-query))))) - (testing "edit mode with single edit" (let [mp (mt/metadata-provider) existing-transform {:id 1 @@ -35,7 +34,6 @@ (is (= "SELECT id FROM customers" (some-> (get-in result [:structured-output :transform :source :query]) lib/raw-native-query))))) - (testing "edit mode with multiple edits" (let [mp (mt/metadata-provider) existing-transform {:id 1 @@ -52,7 +50,6 @@ (is (= "SELECT col_x, col_y FROM table2" (some-> (get-in result [:structured-output :transform :source :query]) lib/raw-native-query))))) - (testing "edit mode fails when text not found" (let [mp (mt/metadata-provider) existing-transform {:id 1 @@ -68,7 +65,6 @@ :edits [{:old_string "nonexistent" :new_string "replacement"}]} :memory-atom memory-atom}))))) - (testing "edit mode fails for ambiguous matches without replace_all" (let [mp (mt/metadata-provider) existing-transform {:id 1 @@ -84,7 +80,6 @@ :edits [{:old_string "foo" :new_string "bar"}]} :memory-atom memory-atom}))))) - (testing "edit mode with replace_all replaces all occurrences" (let [mp (mt/metadata-provider) existing-transform {:id 1 @@ -149,7 +144,6 @@ :memory-atom memory-atom})] (is (= "SELECT 2" (some-> (get-in @memory-atom [:state :transforms "1" :source :query]) lib/raw-native-query))))) - (testing "does not store in memory when no transform_id" (let [memory-atom (atom {:state {:transforms {}}}) _ (transforms-write/write-transform-sql @@ -171,7 +165,6 @@ {:transform_id 999 :edit_action {:mode "replace" :new_content "SELECT 1"} :memory-atom memory-atom}))))) - (testing "fails when edit_action invalid" (let [mp (mt/metadata-provider) existing-transform {:id 1 :name "Existing" :source {:query (lib/native-query mp "select 1")}} diff --git a/test/metabase/metabot/tools/util_test.clj b/test/metabase/metabot/tools/util_test.clj index a5ac74aa3ac4..6e6eb3ca40b2 100644 --- a/test/metabase/metabot/tools/util_test.clj +++ b/test/metabase/metabot/tools/util_test.clj @@ -98,7 +98,6 @@ (perms/grant-collection-read-permissions! group-id metabot-coll) (perms/grant-collection-read-permissions! group-id mb-child-coll1) (perms/grant-collection-read-permissions! group-id mb-child-coll2) - (testing "admin can see all cards in metabot collection and subcollections" (let [admin-result (mt/with-test-user :crowberto (metabot.tools.util/get-metrics-and-models (:id metabot))) @@ -109,7 +108,6 @@ (is (contains? card-ids (:id mb-metric2))) (is (not (contains? card-ids (:id outside-model)))) (is (not (contains? card-ids (:id outside-metric)))))) - (testing "normal user sees only permitted cards" (let [user-result (mt/with-test-user :rasta (metabot.tools.util/get-metrics-and-models (:id metabot))) @@ -145,7 +143,6 @@ mp (lib-be/application-database-metadata-provider test-db-id) orders-query (lib/query mp (lib.metadata/table mp (mt/id :orders))) columns (lib/visible-columns orders-query)] - (testing "adds table-reference for implicitly joined columns" (let [processed-columns (map #(metabot.tools.util/add-table-reference orders-query %) columns) user-name-column (first (filter #(and (= "NAME" (:name %)) @@ -155,26 +152,22 @@ (is (string? (:table-reference user-name-column))) (is (seq (:table-reference user-name-column))) (is (= "User" (:table-reference user-name-column))))) - (testing "does not add table-reference for direct table columns" (let [processed-columns (map #(metabot.tools.util/add-table-reference orders-query %) columns) id-column (first (filter #(and (= "ID" (:name %)) (not (:fk-field-id %))) processed-columns))] (is (some? id-column) "Expected to find direct ORDERS ID column") (is (not (contains? id-column :table-reference))))) - (testing "handles columns without fk-field-id or table-id gracefully" (let [mock-column {:name "test-column" :type :string} result (metabot.tools.util/add-table-reference orders-query mock-column)] (is (= mock-column result)) (is (not (contains? result :table-reference))))) - (testing "handles columns with fk-field-id but no table-id" (let [mock-column {:name "test-fk" :fk-field-id 123} result (metabot.tools.util/add-table-reference orders-query mock-column)] (is (= mock-column result)) (is (not (contains? result :table-reference))))) - (testing "handles columns with table-id but no fk-field-id" (let [mock-column {:name "test-field" :table-id (mt/id :orders)} result (metabot.tools.util/add-table-reference orders-query mock-column)] @@ -196,7 +189,7 @@ :model/Metabot unverified-metabot {:name "unverified metabot" :collection_id (:id metabot-coll) :use_verified_content false}] - ;; Mark some content as verified + ;; Mark some content as verified (moderation/create-review! {:moderated_item_id (:id verified-model) :moderated_item_type "card" :moderator_id (mt/user->id :crowberto) @@ -207,7 +200,6 @@ :moderator_id (mt/user->id :crowberto) :status "verified" :text "This is verified"}) - (testing "metabot with use_verified_content=true sees only verified content" (let [result (mt/with-test-user :crowberto (metabot.tools.util/get-metrics-and-models (:id verified-metabot))) @@ -216,7 +208,6 @@ (is (contains? card-ids (:id verified-metric))) (is (not (contains? card-ids (:id unverified-model)))) (is (not (contains? card-ids (:id unverified-metric)))))) - (testing "metabot with use_verified_content=false sees all content" (let [result (mt/with-test-user :crowberto (metabot.tools.util/get-metrics-and-models (:id unverified-metabot))) @@ -249,26 +240,22 @@ {:id 303 :name "EMAIL"}]] (is (= {:id 301 :name "ID"} (metabot.tools.util/find-column-by-field-id 301 columns))) (is (= {:id 303 :name "EMAIL"} (metabot.tools.util/find-column-by-field-id 303 columns))))) - (testing "finds column by string-encoded field ID" (let [columns [{:id 301 :name "ID"} {:id 302 :name "NAME"}]] (is (= {:id 302 :name "NAME"} (metabot.tools.util/find-column-by-field-id "302" columns))))) - (testing "throws agent error when field ID not found" (let [columns [{:id 301 :name "ID"}]] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Field 999 not found" (metabot.tools.util/find-column-by-field-id 999 columns))))) - (testing "throws for nil field ID" (let [columns [{:id 301 :name "ID"}]] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"not found" (metabot.tools.util/find-column-by-field-id nil columns))))) - (testing "error data contains agent-error? flag" (let [columns [{:id 301 :name "ID"}]] (try diff --git a/test/metabase/metabot/tools_test.clj b/test/metabase/metabot/tools_test.clj index f7326f7a8b49..1584fe8bc0ec 100644 --- a/test/metabase/metabot/tools_test.clj +++ b/test/metabase/metabot/tools_test.clj @@ -23,14 +23,12 @@ (let [tool-vars [#'agent-tools/search-tool #'agent-tools/read-resource-tool]] (is (= tool-vars (#'profiles/filter-by-capabilities tool-vars #{}))))) - (testing "filters out tools that require missing capabilities" (let [tool-vars [#'agent-tools/search-tool #'agent-tools/navigate-user-tool] capabilities #{} result (#'profiles/filter-by-capabilities tool-vars capabilities)] (is (= ["search"] (mapv #(:tool-name (meta %)) result))))) - (testing "includes tools when capabilities are provided" (let [tool-vars [#'agent-tools/search-tool #'agent-tools/navigate-user-tool #'agent-tools/create-chart-tool] capabilities #{:frontend-navigate-user-v1} @@ -182,7 +180,6 @@ (is (contains? (get wrapped-tools "create_sql_query") :schema)) ;; Non-state-dependent tool should also be a tool-def map (is (map? (get wrapped-tools "search"))))) - (testing "wrapped tools preserve original metadata" (let [memory-atom (atom {:state {:queries {} :charts {}}}) base-tools {"create_chart" #'agent-tools/create-chart-tool} @@ -190,7 +187,6 @@ wrapped-tool (get wrapped-tools "create_chart")] (is (= (:doc (meta #'agent-tools/create-chart-tool)) (:doc wrapped-tool))) (is (= (:schema (meta #'agent-tools/create-chart-tool)) (:schema wrapped-tool))))) - (testing "wrapped function receives augmented args with state" (let [memory-atom (atom {:state {:queries {"test-query" {:db 1}} :charts {"test-chart" {:type :bar}}}}) @@ -198,7 +194,6 @@ wrapped-fn (get-in wrapped ["create_sql_query" :fn])] ;; Just verify the wrapped function is callable (is (fn? wrapped-fn)))) - (testing "non-state-dependent tools are also wrapped into tool-def maps" (let [memory-atom (atom {:state {:queries {"q1" {:db 1}} :charts {}}}) base-tools {"search" #'agent-tools/search-tool @@ -215,25 +210,21 @@ (let [{:keys [schema]} (meta #'agent-tools/create-chart-tool) [_:=> [_:cat params] _out] schema] (is (not-any? #(= :charts_state (first %)) (rest params))))) - (testing "edit_chart schema does not expose state keys" (let [{:keys [schema]} (meta #'agent-tools/edit-chart-tool) [_:=> [_:cat params] _out] schema] (is (not-any? #(= :queries_state (first %)) (rest params))))) - (testing "create_sql_query schema does not expose state keys" (let [{:keys [schema]} (meta #'agent-tools/create-sql-query-tool) [_:=> [_:cat params] _out] schema] (is (not-any? #(= :queries_state (first %)) (rest params))) (is (not-any? #(= :charts_state (first %)) (rest params))) (is (not-any? #(= :memory_atom (first %)) (rest params))))) - (testing "edit_sql_query schema does not expose state keys" (let [{:keys [schema]} (meta #'agent-tools/edit-sql-query-tool) [_:=> [_:cat params] _out] schema] (is (not-any? #(= :queries_state (first %)) (rest params))) (is (not-any? #(= :charts_state (first %)) (rest params))))) - (testing "replace_sql_query schema does not expose state keys" (let [{:keys [schema]} (meta #'agent-tools/replace-sql-query-tool) [_:=> [_:cat params] _out] schema] diff --git a/test/metabase/metrics/api_test.clj b/test/metabase/metrics/api_test.clj index 4b8b3b6bd4ad..9c5f13d77b76 100644 --- a/test/metabase/metrics/api_test.clj +++ b/test/metabase/metrics/api_test.clj @@ -322,7 +322,6 @@ {:definition {:expression [:metric {:lib/uuid "a"} (:id metric)] :filters []}})] (is (= "completed" (:status response)))))) - (testing "POST /api/metric/dataset accepts projections parameter (returns 202 even if projections can't be applied)" (mt/with-temp [:model/Card metric {:name "Test Metric" :type :metric diff --git a/test/metabase/model_persistence/api_test.clj b/test/metabase/model_persistence/api_test.clj index 5e738eb64350..4f675d6b6917 100644 --- a/test/metabase/model_persistence/api_test.clj +++ b/test/metabase/model_persistence/api_test.clj @@ -214,12 +214,10 @@ (testing "requires persist setting to be enabled" (is (= "Persisting models is not enabled." (mt/user-http-request :crowberto :post 400 (str "persist/database/" db-id "/persist")))))) - (mt/with-temporary-setting-values [persisted-models-enabled true] (testing "only users with permissions can persist a database" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 (str "persist/database/" db-id "/persist"))))) - (testing "should be able to persit an database" (mt/user-http-request :crowberto :post 204 (str "persist/database/" db-id "/persist")) (is (= "creating" (t2/select-one-fn :state 'PersistedInfo diff --git a/test/metabase/model_persistence/task/persist_refresh_test.clj b/test/metabase/model_persistence/task/persist_refresh_test.clj index 09161029cb74..9e94e3804297 100644 --- a/test/metabase/model_persistence/task/persist_refresh_test.clj +++ b/test/metabase/model_persistence/task/persist_refresh_test.clj @@ -195,7 +195,7 @@ :model/PersistedInfo punmodeled {:card_id (u/the-id unmodeled) :database_id (u/the-id db)} :model/PersistedInfo deletable {:card_id (u/the-id model3) :database_id (u/the-id db) :state "deletable" - ;; need an "old enough" state change + ;; need an "old enough" state change :state_change_at (t/minus (t/local-date-time) (t/hours 2))} ;; Record not in "deletable" state, but with nil card_id :model/PersistedInfo deletable2 {:card_id nil :database_id (u/the-id db)}] @@ -209,7 +209,7 @@ (let [queued-for-deletion (into #{} (map :id) (#'task.persist-refresh/deletable-models))] (doseq [deletable-persisted [deletable punmodeled parchived]] (is (contains? queued-for-deletion (u/the-id deletable-persisted)))))) - ;; we manually pass in the deleteable ones to not catch others in a running instance + ;; we manually pass in the deleteable ones to not catch others in a running instance (#'task.persist-refresh/prune-deletables! test-refresher [deletable parchived punmodeled]) (testing "We delete persisted_info records for all of the pruned" (let [persisted-records (t2/select :model/PersistedInfo :id [:in (map :id [parchived punmodeled deletable])]) @@ -222,7 +222,7 @@ :id) persisted-records)] (is (= [] existing)))) - ;; don't assert equality if there are any deletable in the app db + ;; don't assert equality if there are any deletable in the app db (doseq [deletable-persisted [deletable punmodeled parchived]] (is (contains? @called-on (u/the-id deletable-persisted)))) (is (partial= {:task "unpersist-tables" diff --git a/test/metabase/models/interface_test.clj b/test/metabase/models/interface_test.clj index 6d20ae9bf1a7..f6ed31845d03 100644 --- a/test/metabase/models/interface_test.clj +++ b/test/metabase/models/interface_test.clj @@ -223,7 +223,6 @@ :lib/source :source/table-defaults :lib/source-column-alias "CATEGORY" :lib/type :metadata/column}]] - (is (= cols (#'mi/result-metadata-out (json/encode cols)))))) diff --git a/test/metabase/models/on_demand_test.clj b/test/metabase/models/on_demand_test.clj index b8589e3d9c16..7808b191d32a 100644 --- a/test/metabase/models/on_demand_test.clj +++ b/test/metabase/models/on_demand_test.clj @@ -62,7 +62,6 @@ (testing "in On-Demand DB should get updated FieldValues" (is (true? (field-values-were-updated-for-new-card?! {:db {:is_on_demand true}})))) - (testing "in non-On-Demand DB should *not* get updated FieldValues" (is (= false (field-values-were-updated-for-new-card?! {:db {:is_on_demand false}})))))) diff --git a/test/metabase/models/resolution_test.clj b/test/metabase/models/resolution_test.clj index a3d1967179d8..338b31720ed4 100644 --- a/test/metabase/models/resolution_test.clj +++ b/test/metabase/models/resolution_test.clj @@ -5,7 +5,6 @@ [metabase.classloader.core :as classloader] [metabase.config.core :as config] [metabase.models.resolution :as models.resolution] - [toucan2.core :as t2])) (deftest ^:parallel table-name-resolution-test diff --git a/test/metabase/models/util/spec_update_test.clj b/test/metabase/models/util/spec_update_test.clj index 448f4d4a3726..e9dd07854bf8 100644 --- a/test/metabase/models/util/spec_update_test.clj +++ b/test/metabase/models/util/spec_update_test.clj @@ -124,7 +124,6 @@ (is (= [[:delete! :foo 2]] (with-tracked-operations! (spec-update/do-update! existing-data new-data basic-spec)))))) - (testing "Deleting root record deletes nested model" (let [existing-data {:id 1 :name "Test" @@ -371,7 +370,6 @@ {:name "qux2"}]} {:name "Bar 2"}])) nested-multi-row-spec)))))) - (testing "adding entity of the 2nd nested layer" (let [existing-data {:id 1 :name "foo" @@ -386,7 +384,6 @@ existing-data (update-in existing-data [:bars 0 :quxes] conj {:name "qux1"}) nested-multi-row-spec)))))) - (testing "updating then adding entity of the 2nd nested layer" (let [existing-data {:id 1 :name "foo" diff --git a/test/metabase/native_query_snippets/api_test.clj b/test/metabase/native_query_snippets/api_test.clj index 98ff41ce0ec3..f4e968d76a0e 100644 --- a/test/metabase/native_query_snippets/api_test.clj +++ b/test/metabase/native_query_snippets/api_test.clj @@ -56,19 +56,15 @@ (testing "new snippet field validation" (is (=? {:errors {:content "string"}} (mt/user-http-request :rasta :post 400 (snippet-url) {}))) - (is (name-schema-error? (mt/user-http-request :rasta :post 400 (snippet-url) {:content "NULL"}))) - (is (name-schema-error? (mt/user-http-request :rasta :post 400 (snippet-url) {:content "NULL" :name " starts with a space"}))) - (is (name-schema-error? (mt/user-http-request :rasta :post 400 (snippet-url) {:content "NULL" :name "contains a } character"})))))) - (testing "successful create returns new snippet's data" (doseq [[message user] {"admin user should be able to create" :crowberto "non-admin user should be able to create" :rasta}] @@ -89,7 +85,6 @@ snippet-from-api))) (finally (t2/delete! :model/NativeQuerySnippet :name "test-snippet")))))) - (testing "Attempting to create a Snippet with a name that's already in use should throw an error" (try (mt/with-temp [:model/NativeQuerySnippet _ {:name "test-snippet-1", :content "1"}] @@ -99,7 +94,6 @@ (t2/count :model/NativeQuerySnippet :name "test-snippet-1")))) (finally (t2/delete! :model/NativeQuerySnippet :name "test-snippet-1")))) - (testing "Shouldn't be able to specify non-default creator_id" (try (let [snippet (mt/user-http-request :crowberto :post 200 (snippet-url) @@ -129,14 +123,12 @@ (testing "\nobject in application DB" (is (=? {:collection_id collection-id} db))))) - (testing "\nShould throw an error if the Collection isn't in the 'snippets' namespace" (mt/with-temp [:model/Collection {collection-id :id}] (is (= {:errors {:collection_id "A NativeQuerySnippet can only go in Collections in the :snippets namespace."} :allowed-namespaces ["snippets"] :collection-namespace nil} (:response (create! 400 collection-id)))))) - (testing "\nShould throw an error if Collection does not exist" (is (= {:errors {:collection_id "Collection does not exist."}} (:response (create! 404 Integer/MAX_VALUE)))))))))) @@ -154,7 +146,6 @@ updated-snippet (mt/user-http-request user :put 200 (snippet-url (:id snippet)) {:description updated-desc})] (is (= updated-desc (:description updated-snippet))))))) - (testing "Attempting to change Snippet's name to one that's already in use should throw an error" (mt/with-temp [:model/NativeQuerySnippet _ {:name "test-snippet-1" :content "1"} :model/NativeQuerySnippet snippet-2 {:name "test-snippet-2" :content "2"}] @@ -162,12 +153,10 @@ (mt/user-http-request :crowberto :put 400 (snippet-url (:id snippet-2)) {:name "test-snippet-1"}))) (is (= 1 (t2/count :model/NativeQuerySnippet :name "test-snippet-1"))) - (testing "Passing in the existing name (no change) shouldn't cause an error" (is (= {:id (:id snippet-2), :name "test-snippet-2"} (select-keys (mt/user-http-request :crowberto :put 200 (snippet-url (:id snippet-2)) {:name "test-snippet-2"}) [:id :name])))))) - (testing "Shouldn't be able to change creator_id" (mt/with-temp [:model/NativeQuerySnippet snippet {:name "test-snippet", :content "1", :creator_id (mt/user->id :lucky)}] (mt/user-http-request :crowberto :put 200 (snippet-url (:id snippet)) {:creator_id (mt/user->id :rasta)}) @@ -193,7 +182,6 @@ (testing "\nvalue in app DB" (is (= (:id dest) (t2/select-one-fn :collection_id :model/NativeQuerySnippet :id snippet-id))))))))) - (testing "\nShould throw an error if you try to move it to a Collection not in the 'snippets' namespace" (mt/with-temp [:model/Collection {collection-id :id} {} :model/NativeQuerySnippet {snippet-id :id} {}] @@ -201,7 +189,6 @@ :allowed-namespaces ["snippets"] :collection-namespace nil} (mt/user-http-request :rasta :put 400 (snippet-url snippet-id) {:collection_id collection-id}))))) - (testing "\nShould throw an error if Collection does not exist" (mt/with-temp [:model/NativeQuerySnippet {snippet-id :id}] (is (= {:errors {:collection_id "Collection does not exist."}} diff --git a/test/metabase/native_query_snippets/models/native_query_snippet_test.clj b/test/metabase/native_query_snippets/models/native_query_snippet_test.clj index 53269eddf420..4c3cdbf1d1bf 100644 --- a/test/metabase/native_query_snippets/models/native_query_snippet_test.clj +++ b/test/metabase/native_query_snippets/models/native_query_snippet_test.clj @@ -23,7 +23,6 @@ :model/NativeQuerySnippet {snippet-id :id} {:collection_id collection-id}] (is (= collection-id (t2/select-one-fn :collection_id :model/NativeQuerySnippet :id snippet-id))))) - (doseq [[source dest] [[nil "snippets"] ["snippets" "snippets"] ["snippets" nil]]] @@ -37,7 +36,6 @@ (t2/update! :model/NativeQuerySnippet snippet-id {:collection_id (when dest dest-collection-id)}) (is (= (when dest dest-collection-id) (t2/select-one-fn :collection_id :model/NativeQuerySnippet :id snippet-id)))))) - (doseq [collection-namespace [nil "x"]] (testing (format "Should *not* be allowed to create snippets in a Collection in the %s namespace" (pr-str collection-namespace)) @@ -50,7 +48,6 @@ :content "1 = 1" :creator_id (mt/user->id :rasta) :collection_id collection-id}))))) - (testing (format "Should *not* be allowed to move snippets into a Collection in the namespace %s" (pr-str collection-namespace)) (mt/with-temp [:model/Collection {source-collection-id :id} {:namespace "snippets"} :model/NativeQuerySnippet {snippet-id :id} {:collection_id source-collection-id} @@ -114,7 +111,6 @@ (deftest template-tags-serialization-test (testing "Template tags serialization preserves nil, empty, and populated states" (mt/with-temp [:model/User {user-id :id} {:email "test@example.com"}] - (testing "nil in -> {} out" (let [snippet (t2/insert-returning-instance! :model/NativeQuerySnippet {:name "nil-tags" @@ -125,7 +121,6 @@ ;; toucan hooks populate it: (is (= {} (:template_tags extracted))) (t2/delete! :model/NativeQuerySnippet :id (:id snippet)))) - (testing "empty map in -> empty map out" (mt/with-temp [:model/NativeQuerySnippet snippet {:name "empty-tags" @@ -134,7 +129,6 @@ :template_tags {}}] (let [extracted (serdes/extract-one "NativeQuerySnippet" {} snippet)] (is (= {} (:template_tags extracted)))))) - (testing "tags in -> tags out" (mt/with-temp [:model/NativeQuerySnippet snippet {:name "with-tags" diff --git a/test/metabase/notification/api/notification_test.clj b/test/metabase/notification/api/notification_test.clj index d01757e612ea..2ae16f17bd25 100644 --- a/test/metabase/notification/api/notification_test.clj +++ b/test/metabase/notification/api/notification_test.clj @@ -95,7 +95,6 @@ :user_id (mt/user->id :crowberto)}]}]}] (is (=? (assoc notification :id (mt/malli=? int?)) (mt/user-http-request :crowberto :post 200 "notification" notification))))) - (testing "card notification with no subscriptions and handler is ok" (let [notification {:payload_type "notification/card" :active true @@ -200,12 +199,10 @@ (deftest create-notification-error-test (testing "require auth" (is (= "Unauthenticated" (mt/client :post 401 "notification")))) - (testing "card notification requires a card_id" (is (=? {:specific-errors {:payload {:card_id ["missing required key, received: nil"]}}} (mt/user-http-request :crowberto :post 400 "notification" {:payload {} :payload_type "notification/card"})))) - (mt/with-model-cleanup [:model/Notification] (mt/with-temp [:model/Card {card-id :id}] (testing "creator id is not required" @@ -240,13 +237,11 @@ (assoc :name "New Name") (dissoc :updated_at :created_at)) (dissoc updated-template :updated_at :created_at))))) - (testing "can delete the template" (mt/user-http-request :crowberto :put 200 (format "notification/%d" (:id notification)) (update notification :handlers (fn [[handler]] [(dissoc handler :template)]))) (is (false? (t2/exists? :model/ChannelTemplate (:id created-template))))) - (testing "and re-create it again" (let [notification (mt/user-http-request :crowberto :put 200 (format "notification/%d" (:id notification)) (update notification :handlers (fn [[handler]] @@ -273,7 +268,6 @@ :template resource-template :recipients [{:type "notification-recipient/user" :user_id (mt/user->id :crowberto)}]}]})))))) - (testing "POST /api/notification/send rejects handlebars-resource templates" (mt/with-temp [:model/Card {card-id :id} {}] (is (=? "invalid template" @@ -285,7 +279,6 @@ :template resource-template :recipients [{:type "notification-recipient/user" :user_id (mt/user->id :crowberto)}]}]}))))) - (testing "PUT /api/notification/:id rejects handlebars-resource templates" (notification.tu/with-card-notification [notification {:handlers [{:channel_type "channel/email" @@ -330,12 +323,10 @@ :cron_schedule "1 1 1 * * ?" :ui_display_type "cron/builder"}] (:subscriptions (update-notification (update-cron-subscription @notification "1 1 1 * * ?" "cron/builder")))))) - (testing "can update payload info" (is (= "has_result" (get-in @notification [:payload :send_condition]))) (is (=? {:send_condition "goal_above"} (:payload (update-notification (assoc-in @notification [:payload :send_condition] "goal_above")))))) - (testing "can add add a new recipient and modify the existing one" (let [existing-email-handler (->> @notification :handlers (m/find-first #(= "channel/email" (:channel_type %)))) existing-user-recipient (m/find-first #(= "notification-recipient/user" (:type %)) @@ -355,7 +346,6 @@ (is (= [] (->> (update-notification (assoc @notification :handlers [(assoc existing-email-handler :recipients [])])) :handlers (m/find-first #(= "channel/email" (:channel_type %))) :recipients)))))) - (testing "can add new handler" (let [new-handler {:notification_id notification-id :channel_type :channel/slack @@ -402,7 +392,6 @@ (deftest update-notification-error-test (testing "require auth" (is (= "Unauthenticated" (mt/client :put 401 "notification/1")))) - (testing "404 on unknown notification" (is (= "Not found." (mt/user-http-request :crowberto :put (format "notification/%d" Integer/MAX_VALUE) @@ -438,7 +427,6 @@ :channel/http [{:body (mt/malli=? some?)}]} (notification.tu/with-captured-channel-send! (mt/user-http-request :crowberto :post 204 (format "notification/%d/send" (:id notification))))))) - (testing "select handlers" (let [handler-ids (->> (:handlers notification) (filter (comp #{:channel/slack :channel/http} :channel_type)) @@ -477,7 +465,6 @@ :send_once false} :subscriptions [{:type :notification-subscription/cron :cron_schedule "0 0 0 * * ?"}]})))))) - (testing "links disabled/enabled based on x-metabase-client header" (let [notification-body {:handlers [{:channel_type :channel/email :recipients [{:type :notification-recipient/user @@ -519,13 +506,10 @@ (mt/user-http-request user-or-id :get expected-status (format "notification/%d" (:id notification))))] (testing "admin can view" (get-notification :crowberto 200)) - (testing "creator can view" (get-notification :rasta 200)) - (testing "recipient can view" (get-notification :lucky 200)) - (testing "other than that no one can view" (get-notification third-user-id 403)))))) @@ -537,10 +521,8 @@ (mt/user-http-request user-or-id :get expected-status (format "notification/%d" (:id notification))))] (testing "admin can view" (get-notification :crowberto 200)) - (testing "creator can view" (get-notification :rasta 200)) - (testing "other than that no one can view" (get-notification :lucky 403))))) @@ -569,20 +551,16 @@ (mt/with-premium-features #{} (testing "admin can create" (create-notification! :crowberto 200)) - (testing "users who can view the card can create" (create-notification! user 200)) - (testing "normal users can't create" (create-notification! :rasta 403)) - (mt/when-ee-evailable (mt/with-premium-features #{:advanced-permissions} (testing "with advanced-permissions enabled" (testing "cannot create if they don't have subscriptions permissions enabled" (create-notification! user 403) (create-notification! :rasta 403)) - (testing "can create if they have subscriptions permissions enabled" (perms/grant-application-permissions! group :subscription) (create-notification! user 200) @@ -608,10 +586,8 @@ (mt/with-premium-features #{} (testing "admin can update" (update! :crowberto 200)) - (testing "owner can update" (update! :rasta 200)) - (testing "owner can't no longer update if they can't view the card" (try ;; card is moved to crowberto's collection @@ -620,10 +596,8 @@ (finally ;; move it back (move-card-collection (mt/user->id :rasta))))) - (testing "other than that noone can update" (update! :lucky 403)) - (mt/when-ee-evailable ;; change notification's creator to user for easy of testing (with-disabled-subscriptions-permissions! @@ -661,13 +635,10 @@ (mt/user-http-request user-or-id :get expected-status (format "notification/%d" (:id notification))))] (testing "admin can send" (send-notification :crowberto 200)) - (testing "creator can send" (send-notification :rasta 200)) - (testing "recipient can send" (send-notification :lucky 200)) - (testing "other than that no one can send" (send-notification third-user-id 403)))))) @@ -690,13 +661,10 @@ (mt/with-premium-features #{} (testing "admin can send" (create-notification! :crowberto 200)) - (testing "users who can view the card can send" (create-notification! (:id user) 200)) - (testing "normal users can't send" (create-notification! :rasta 403)) - (mt/when-ee-evailable (with-disabled-subscriptions-permissions! (mt/with-premium-features #{:advanced-permissions} @@ -704,7 +672,6 @@ (testing "can't send if don't have subscription permissions" (perms/revoke-application-permissions! group :subscription) (create-notification! (:id user) 403)) - (testing "can send if advanced-permissions is enabled" (perms/grant-application-permissions! group :subscription) (create-notification! (:id user) 200))))))))))))) @@ -725,11 +692,9 @@ (map :id) (filter #{rasta-noti-1 crowberto-noti-1 rasta-noti-2}) set))] - (testing "returns all active notifications by default" (is (= #{rasta-noti-1 crowberto-noti-1} (get-notification-ids :crowberto)))) - (testing "include inactive notifications" (is (= #{rasta-noti-1 crowberto-noti-1 rasta-noti-2} (get-notification-ids :crowberto :include_inactive true))))))))))) @@ -746,24 +711,19 @@ (map :id) (filter #{rasta-noti}) set))] - (testing "admin can view" (is (= #{rasta-noti} (get-notification-ids :crowberto :creator_id (mt/user->id :rasta))))) - (testing "creators can view notifications they created" (is (= #{rasta-noti} (get-notification-ids :rasta :creator_id (mt/user->id :rasta))))) - (testing "recipients can view" (is (= #{rasta-noti} (get-notification-ids :lucky :creator_id (mt/user->id :rasta))))) - (testing "other than that no one can view" (mt/with-temp [:model/User {third-user-id :id} {:is_superuser false}] (is (= #{} (get-notification-ids third-user-id :creator_id (mt/user->id :rasta)))))) - (testing "non-existent creator id returns empty set" (is (= #{} (get-notification-ids :crowberto :creator_id Integer/MAX_VALUE))))))))) @@ -780,24 +740,19 @@ (map :id) (filter #{rasta-noti}) set))] - (testing "admin can view" (is (= #{rasta-noti} (get-notification-ids :crowberto :recipient_id (mt/user->id :lucky))))) - (testing "recipients can view notifications they receive" (is (= #{rasta-noti} (get-notification-ids :lucky :recipient_id (mt/user->id :lucky))))) - (testing "creators can view" (is (= #{rasta-noti} (get-notification-ids :rasta :recipient_id (mt/user->id :lucky))))) - (testing "other than that no one can view" (mt/with-temp [:model/User {third-user-id :id} {:is_superuser false}] (is (= #{} (get-notification-ids third-user-id :recipient_id (mt/user->id :lucky)))))) - (testing "non-existent recipient id returns empty set" (is (= #{} (get-notification-ids :crowberto :recipient_id Integer/MAX_VALUE))))))))) @@ -815,13 +770,11 @@ :handlers [{:channel_type "channel/email" :recipients [{:type :notification-recipient/user :user_id (mt/user->id :rasta)}]}]}] - (letfn [(get-notification-ids [user & params] (->> (apply mt/user-http-request user :get 200 "notification" params) (map :id) (filter #{rasta-noti lucky-noti}) sort))] - (testing "return notifications where user is either creator or recipient" (is (= (sort [rasta-noti lucky-noti]) (get-notification-ids :crowberto :creator_or_recipient_id (mt/user->id :rasta))))))))))) @@ -842,24 +795,19 @@ (map :id) (filter #{rasta-noti}) set))] - (testing "admin can view" (is (= #{rasta-noti} (get-notification-ids :crowberto :card_id card-id)))) - (testing "creators can view notifications with their cards" (is (= #{rasta-noti} (get-notification-ids :rasta :card_id card-id)))) - (testing "recipients can view" (is (= #{rasta-noti} (get-notification-ids :lucky :card_id card-id)))) - (testing "other than that no one can view" (mt/with-temp [:model/User {third-user-id :id} {:is_superuser false}] (is (= #{} (get-notification-ids third-user-id :card_id card-id))))) - (testing "non-existent card id returns empty set" (is (= #{} (get-notification-ids :crowberto :card_id Integer/MAX_VALUE)))))))))) @@ -880,32 +828,27 @@ (map :id) (filter #{rasta-noti}) set))] - (testing "can filter by creator_id and recipient_id" (is (= #{rasta-noti} (get-notification-ids :crowberto :creator_id (mt/user->id :rasta) :recipient_id (mt/user->id :lucky))))) - (testing "can filter by creator_id and card_id" (is (= #{rasta-noti} (get-notification-ids :crowberto :creator_id (mt/user->id :rasta) :card_id card-id)))) - (testing "can filter by recipient_id and card_id" (is (= #{rasta-noti} (get-notification-ids :crowberto :recipient_id (mt/user->id :lucky) :card_id card-id)))) - (testing "can filter by all three" (is (= #{rasta-noti} (get-notification-ids :crowberto :creator_id (mt/user->id :rasta) :recipient_id (mt/user->id :lucky) :card_id card-id)))) - (testing "returns empty set when any filter doesn't match" (is (= #{} (get-notification-ids :crowberto @@ -935,7 +878,6 @@ [{:type :notification-recipient/user :user_id (mt/user->id :lucky)}] (email-recipients noti)))))) - (testing "recipient can unsubscribe themselves" (unsbuscribe :lucky 204 @@ -944,7 +886,6 @@ [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}] (email-recipients noti)))))) - (testing "other than that no one can unsubscribe" (unsbuscribe :rasta 403 @@ -976,7 +917,6 @@ :user_id (mt/user->id :lucky)}]}]}] ;; Unsubscribe from first notification (mt/user-http-request :lucky :post 204 (format "notification/%d/unsubscribe" noti-1)) - ;; Check first notification has no recipients ;; First notification should have no recipients (is (empty? (email-recipients noti-1))) @@ -1051,7 +991,6 @@ :subject expected-subject :body [{card-url-tag true}]} (mt/summarize-multipart-single-email email (re-pattern card-url-tag)))))] - (testing "when notification is archived (active -> inactive)" (notification.tu/with-card-notification [{noti-id :id :as notification} base-notification] @@ -1061,7 +1000,6 @@ :expected-bcc #{"rasta@metabase.com" "test@metabase.com"} :expected-subject "You’ve been unsubscribed from an alert" :card-url-tag card-url-tag)))) - (testing "when notification is archived (active -> inactive) with disable_links value:" (let [has-link? (fn [disable_links] (notification.tu/with-card-notification @@ -1076,7 +1014,6 @@ (is (true? (has-link? nil)))) (testing "true will remove all links in the alert unsubscribe email" (is (false? (has-link? true)))))) - (testing "when notification is unarchived (inactive -> active)" (notification.tu/with-card-notification [{noti-id :id :as notification} (assoc-in base-notification [:notification :active] false)] @@ -1086,7 +1023,6 @@ :expected-bcc #{"rasta@metabase.com" "test@metabase.com"} :expected-subject "Crowberto Corv added you to an alert" :card-url-tag card-url-tag)))) - (testing "when recipients are modified" (notification.tu/with-card-notification [{noti-id :id :as notification} base-notification] @@ -1100,19 +1036,16 @@ [removed-email added-email] (update-notification! noti-id notification (assoc-in notification [:handlers 0 :recipients] updated-recipients)) card-url-tag (make-card-url-tag notification)] - (testing "sends unsubscribe email to removed recipients" (check-email :email removed-email :expected-bcc #{"rasta@metabase.com" "test@metabase.com"} :expected-subject "You’ve been unsubscribed from an alert" :card-url-tag card-url-tag)) - (testing "sends subscription email to new recipients" (check-email :email added-email :expected-bcc #{"lucky@metabase.com" "new@metabase.com"} :expected-subject "Crowberto Corv added you to an alert" :card-url-tag card-url-tag))))) - (testing "no emails sent when recipients haven't changed" (notification.tu/with-card-notification [{noti-id :id :as notification} base-notification] @@ -1142,21 +1075,17 @@ (testing "fail if recipients does not match allowed domains" (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" (mt/user-http-request :crowberto :post 403 "notification" (assoc notification :handlers failed-handlers))))) - (testing "success if recipients matches allowed domains" (mt/user-http-request :crowberto :post 200 "notification" (assoc notification :handlers success-handlers)))) - (testing "on update" (notification.tu/with-card-notification [notification {}] (testing "fail if recipients does not match allowed domains" (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" (mt/user-http-request :crowberto :put 403 (format "notification/%d" (:id notification)) (assoc notification :handlers failed-handlers))))) - (testing "success if recipients matches allowed domains" (mt/user-http-request :crowberto :put 200 (format "notification/%d" (:id notification)) (assoc notification :handlers success-handlers))))) - (testing "on send test" (testing "fail if recipients does not match allowed domains" (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" diff --git a/test/metabase/notification/api/unsubscribe_test.clj b/test/metabase/notification/api/unsubscribe_test.clj index b6b002af6cf5..b604e9de64f7 100644 --- a/test/metabase/notification/api/unsubscribe_test.clj +++ b/test/metabase/notification/api/unsubscribe_test.clj @@ -13,7 +13,6 @@ expected-hash "f3cfa7bc3021186b2abeceac80c3e75524457203e54d27744672e320c65df51a98674961b38683d84d8b36f4b12b310489235dd08e5a9b8464dc8fec51c3d3f4"] (testing "We generate a cryptographic hash to validate unsubscribe URLs" (is (= expected-hash (messages/generate-notification-unsubscribe-hash notification-handler-id email)))) - (testing "The hash value depends on the notification-id, email, and site-uuid" (let [alternate-site-uuid "aa147515-ade9-4298-ac5f-c7e42b69286d" alternate-hashes [(messages/generate-notification-unsubscribe-hash 87654321 email) @@ -50,7 +49,6 @@ (testing "Invalid hash" (is (= "Invalid hash." (api:unsubscribe 400 1 email "fake-hash")))) - (testing "Valid hash but email doesn't exist" (notification.tu/with-card-notification [notification {:handlers [{:channel_type :channel/email @@ -58,7 +56,6 @@ (let [handler-id (-> notification :handlers first :id)] (is (= "Email doesn't exist." (api:unsubscribe 400 handler-id email)))))) - (testing "Valid hash and email" (notification.tu/with-card-notification [notification {:handlers [{:channel_type :channel/email @@ -83,7 +80,6 @@ (testing "Invalid hash" (is (= "Invalid hash." (api:unsubscribe-undo 400 1 email "fake-hash")))) - (testing "Valid hash and email doesn't exist (should succeed and create recipient)" (notification.tu/with-card-notification [notification {:handlers [{:channel_type :channel/email @@ -102,7 +98,6 @@ :model_id handler-id :details {:email "test@metabase.com"}} (mt/latest-audit-log-entry :notification-unsubscribe-undo-ex))))) - (testing "Valid hash but email already exists" (notification.tu/with-card-notification [notification {:handlers [{:channel_type :channel/email diff --git a/test/metabase/notification/models_test.clj b/test/metabase/notification/models_test.clj index 14ff53bc9e8a..6b832c18fd3d 100644 --- a/test/metabase/notification/models_test.clj +++ b/test/metabase/notification/models_test.clj @@ -38,7 +38,6 @@ (t2/insert-returning-pk! :model/Notification {:payload_type :notification/system-event :created_at :%now :updated_at :%now}))))) - (testing "failed if payload_type is invalid" (is (thrown-with-msg? Exception #"Value does not match schema*" (t2/insert! :model/Notification {:payload_type :notification/not-existed})))))) @@ -60,7 +59,6 @@ java.lang.Exception #"Update payload_id is not allowed." (t2/update! :model/Notification noti-id {:payload_id 1338}))))) - (testing "can't change creator id" (mt/with-temp [:model/Notification {noti-id :id} {:payload_type :notification/card :payload_id 1337 @@ -116,7 +114,6 @@ :event_name :event/card-create :notification_id n-id})] (is (some? (t2/select-one :model/NotificationSubscription sub-id))))) - (testing "fail if type is system event but event-name is nil" (is (thrown-with-msg? Exception #"Value does not match schema" (t2/insert! :model/NotificationSubscription {:type :notification-subscription/system-event @@ -144,7 +141,6 @@ :event_name random-event :notification_id n-id})] (is (some? (t2/select-one :model/NotificationSubscription sub-id))))) - (testing "failed if type is invalid" (is (thrown-with-msg? Exception #"Must be a namespaced keyword under :event, got: :user-join" (t2/insert! :model/NotificationSubscription {:type :notification-subscription/system-event @@ -178,12 +174,10 @@ :channel_id (:id chn-2) :recipients [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}]}])] - (testing "hydrate subscriptions" (is (=? [default-user-invited-subscription default-card-created-subscription] (:subscriptions (t2/hydrate noti :subscriptions))))) - (testing "hydrate handlers" (is (=? [{:channel_type (:type chn-1) :channel_id (:id chn-1) @@ -192,7 +186,6 @@ :channel_id (:id chn-2) :template_id nil}] (:handlers (t2/hydrate noti :handlers))))) - (let [noti-handler (t2/select-one :model/NotificationHandler :channel_id (:id chn-1) :template_id (:id tmpl))] (testing "hydrate template + channel" (is (=? {:channel_type (:type chn-1) @@ -203,7 +196,6 @@ :template {:id (:id tmpl) :name "My Template"}} (t2/hydrate noti-handler :template :channel)))) - (testing "hydrate recipients will also hydrate users and members of groups" (is (=? [{:type :notification-recipient/user :user_id (mt/user->id :rasta) @@ -242,7 +234,6 @@ (t2/insert! :model/NotificationHandler {:channel_type :channel/slack :channel_id (:id chn-1) :template_id (:id tmpl-1)}))))) - (testing "can't update a handler with a template that has different channel type" (mt/with-temp [:model/ChannelTemplate email-tmpl notification.tu/channel-template-email-with-handlebars-body :model/ChannelTemplate slack-tmpl {:channel_type :channel/slack} @@ -265,7 +256,6 @@ (testing "success with user_id" (is (some? (insert! {:type :notification-recipient/user :user_id (mt/user->id :rasta)})))) - (testing "fail without user_id" (is (thrown-with-msg? Exception #"Value does not match schema" (insert! {:type :notification-recipient/user})))) @@ -283,7 +273,6 @@ (testing "success with group_id" (is (some? (insert! {:type :notification-recipient/group :permissions_group_id (t2/select-one-pk :model/PermissionsGroup)})))) - (testing "fail without group_id" (is (thrown-with-msg? Exception #"Value does not match schema" (insert! {:type :notification-recipient/group})))) @@ -301,7 +290,6 @@ (testing "success with value" (is (some? (insert! {:type :notification-recipient/raw-value :details {:value "ngoc@metabase.com"}})))) - (testing "fail if details does not match schema" (is (thrown-with-msg? Exception #"Value does not match schema" (insert! {:type :notification-recipient/raw-value @@ -339,13 +327,11 @@ sub-id "1 * * * * ? *")] (notification.tu/send-notification-triggers sub-id)))) - (testing "delete the trigger when type changes" (t2/update! :model/NotificationSubscription sub-id {:type :notification-subscription/system-event :cron_schedule nil :event_name :event/card-create}) (is (empty? (notification.tu/send-notification-triggers sub-id)))))) - (testing "delete the trigger when delete subscription" (let [sub-id (t2/insert-returning-pk! :model/NotificationSubscription {:type :notification-subscription/cron :cron_schedule "0 * * * * ? *" @@ -353,7 +339,6 @@ (is (not-empty (notification.tu/send-notification-triggers sub-id))) (t2/delete! :model/NotificationSubscription sub-id) (is (empty? (notification.tu/send-notification-triggers sub-id))))) - (testing "delete notification will delete all subscription triggers" (let [sub-id (t2/insert-returning-pk! :model/NotificationSubscription {:type :notification-subscription/cron :cron_schedule "0 * * * * ? *" @@ -388,11 +373,9 @@ :cron_schedule "1 * * * * ? *"}]}] (testing "sanity check that it has a trigger to begin with" (is (= 2 (count (notification.tu/notification-triggers id))))) - (testing "disabled notification should remove triggers" (t2/update! :model/Notification id {:active false}) (is (empty? (notification.tu/notification-triggers id)))) - (testing "activate notification should restore triggers" (t2/update! :model/Notification id {:active true}) (is (= 2 (count (notification.tu/notification-triggers id)))))))) diff --git a/test/metabase/notification/payload/impl/card_test.clj b/test/metabase/notification/payload/impl/card_test.clj index d7de360e20bd..1d5f79b153ab 100644 --- a/test/metabase/notification/payload/impl/card_test.clj +++ b/test/metabase/notification/payload/impl/card_test.clj @@ -160,7 +160,7 @@ (is (= (construct-email {:message [{notification.tu/default-card-name true "Manage your subscriptions" true} - ;; icon + ;; icon notification.tu/png-attachment notification.tu/csv-attachment]}) (mt/summarize-multipart-single-email @@ -207,7 +207,6 @@ (mt/with-temporary-setting-values [attachment-row-limit 10] (notification.tu/with-card-notification [notification {:card {:dataset_query (mt/mbql-query orders)} :handlers [@notification.tu/default-email-handler]}] - (notification.tu/test-send-notification! notification {:channel/email @@ -226,7 +225,6 @@ :user_id (mt/user->id :rasta)} {:type :notification-recipient/raw-value :details {:value "ngoc@metabase.com"}}]}]}] - (notification.tu/test-send-notification! notification {:channel/email @@ -345,7 +343,6 @@ {:channel/email (fn [emails] (is (empty? emails)))}))) - (testing "send if goal is met" (notification.tu/with-card-notification [notification {:card {:dataset_query (mt/mbql-query @@ -396,7 +393,6 @@ {:channel/email (fn [emails] (is (empty? emails)))}))) - (testing "send if goal is met" (notification.tu/with-card-notification [notification {:card {:dataset_query (mt/mbql-query @@ -435,7 +431,6 @@ (mt/with-dynamic-fn-redefs [notification.payload/notification-payload (fn [& _args] (throw (ex-info "error" {})))] (u/ignore-exceptions (notification/send-notification! notification)) (is (true? (t2/select-one-fn :active :model/Notification (:id notification)))))) - (testing "archive if the send is successful" (notification/send-notification! notification) (is (false? (t2/select-one-fn :active :model/Notification (:id notification)))) @@ -468,7 +463,6 @@ (fn [emails] (is (zero? (count emails))) (is (true? (t2/select-one-fn :active :model/Notification (:id notification)))))})) - (testing "if the goal is met, notification is sent then archived" ;; flip the condition so the goal is met now (t2/update! :model/NotificationCard (get-in notification [:payload :id]) {:send_condition :goal_above}) @@ -533,7 +527,6 @@ (notification.tu/with-card-notification [notification {:handlers [@notification.tu/default-email-handler notification.tu/default-slack-handler]}] - (let [original-render-noti (var-get #'channel/render-notification)] (with-redefs [channel/render-notification (fn [& args] (if (= :channel/slack (first args)) @@ -623,7 +616,6 @@ {:channel/email (fn [emails] (is (= 18761 (email->attachment-line-count (first emails)))))}))) - (testing "respect attachment limit env if set" (mt/with-temporary-setting-values [attachment-row-limit 10] (notification.tu/with-card-notification @@ -634,7 +626,6 @@ {:channel/email (fn [emails] (is (= 11 (email->attachment-line-count (first emails)))))})))) - (testing "respect query limit if set" (notification.tu/with-card-notification [notification {:card {:dataset_query (mt/mbql-query orders {:limit 10})} @@ -644,7 +635,6 @@ {:channel/email (fn [emails] (is (= 11 (email->attachment-line-count (first emails)))))}))) - (testing "attachment limit env > query limit" (mt/with-temporary-setting-values [attachment-row-limit 10] (notification.tu/with-card-notification diff --git a/test/metabase/notification/payload/impl/system_event_test.clj b/test/metabase/notification/payload/impl/system_event_test.clj index 086e4535ffb5..f42ef8ccbe64 100644 --- a/test/metabase/notification/payload/impl/system_event_test.clj +++ b/test/metabase/notification/payload/impl/system_event_test.clj @@ -131,7 +131,6 @@ [#"Ngoc wants you to join them on Metabase" #"]*href=\"https?://metabase\.com/auth/reset_password/.*#new\"[^>]*>Join now"] "Ngoc") - (testing "with sso enabled" (with-redefs [sso.settings/sso-enabled? (constantly true) session.settings/enable-password-login (constantly false)] @@ -139,20 +138,17 @@ "You're invited to join SuperStar's Metabase" [#"]*href=\"https?://metabase\.com/auth/login\"[^>]*>Join now"] "Ngoc"))) - (testing "with invitor's first_name not defined" (check false "You're invited to join SuperStar's Metabase" [#"You are invited to join Metabase" #"]*href=\"https?://metabase\.com/auth/reset_password/.*#new\"[^>]*>Join now"] nil))) - (testing "subject is translated" (mt/with-mock-i18n-bundles! {"es" {:messages {"You''re invited to join {0}''s {1}" "Estás invitado a unirte al {0} de {1}"}}} (mt/with-temporary-setting-values [site-locale "es"] (check false "Estás invitado a unirte al SuperStar de Metabase" [] "Ngoc")))) - (testing "sent from setup page" (check true "You're invited to join SuperStar's Metabase" @@ -160,7 +156,6 @@ #"Your Metabase is up and running, but Kratos needs you to connect your data. You'll probably need:" #"]*href=\"https?://metabase\.com/auth/reset_password/.*\?redirect(=|=)/admin/databases/create.*#new\"[^>]*>"] "Kratos") - (testing "with invitor's first_name not defined" (check true "You're invited to join SuperStar's Metabase" @@ -168,14 +163,12 @@ #"Your Metabase is up and running, but your help is needed to connect data. You'll probably need:" #"]*href=\"https?://metabase\.com/auth/reset_password/.*\?redirect(=|=)/admin/databases/create.*#new\"[^>]*>"] nil))) - (testing "with custom application logo (external URL)" (mt/with-premium-features #{:whitelabel} (check false "You're invited to join SuperStar's Metabase" [#"]*src=\"https://metabase\.com/superstar\.png\"[^>]*>"] "Ngoc"))) - (testing "with custom application logo (data URI - embedded as attachment)" (mt/with-premium-features #{:whitelabel} (let [email (mt/with-temporary-setting-values @@ -187,9 +180,9 @@ {:first_name "Ngoc" :email "ngoc@metabase.com"} false)) :channel/email first))] - ;; The logo should be embedded as an attachment with a cid: reference + ;; The logo should be embedded as an attachment with a cid: reference (is (re-find #"]*src=\"cid:[^\"]+@metabase\"[^>]*>" (-> email :message first :content))) - ;; There should be an attachment for the logo + ;; There should be an attachment for the logo (is (some #(and (= (:type %) :inline) (= (:content-type %) "image/png")) (rest (:message email))))))))) @@ -216,7 +209,6 @@ :message [(zipmap (map str regexes) (repeat true))] :recipient-type :cc} (apply mt/summarize-multipart-single-email email regexes))))))] - (doseq [[send-condition condition-regex] [[:has_result #"This alert will be sent\s+whenever this question has any results"] @@ -225,7 +217,6 @@ [:goal_below #"This alert will be sent\s+when this question goes below its goal"]]] (check send-condition condition-regex)))) - (notification.tu/with-notification-testing-setup! (notification.tu/with-card-notification [notification {:card {:name "A Card"} @@ -262,7 +253,6 @@ (testing "send to admins with a link to setting page" (check admin-emails [#"Your Slack connection stopped working" #"]*href=\"https?://metabase\.com/admin/settings/slack\"[^>]*>Go to settings"])) - (mt/with-temporary-setting-values [admin-email "it@metabase.com"] (check (conj admin-emails "it@metabase.com") [])))) diff --git a/test/metabase/notification/payload/temp_storage_test.clj b/test/metabase/notification/payload/temp_storage_test.clj index da53f4375e50..276ca860b0a0 100644 --- a/test/metabase/notification/payload/temp_storage_test.clj +++ b/test/metabase/notification/payload/temp_storage_test.clj @@ -26,7 +26,6 @@ (is (= :completed (:status result))) (is (vector? (get-in result [:data :rows]))) (is (= (rows 4) (get-in result [:data :rows]))))) - (testing "At threshold - rows stream to disk" (let [result (run-rff 50 (rows 5)) storage (get-in result [:data :rows])] @@ -34,7 +33,6 @@ (is (temp-storage/streaming-temp-file? storage)) (is (= (rows 5) @storage)) (temp-storage/cleanup! storage))) - (testing "Over threshold - rows stream to disk" (let [result (run-rff 50 (rows 6)) storage (get-in result [:data :rows])] @@ -42,7 +40,6 @@ (is (temp-storage/streaming-temp-file? storage)) (is (= (rows 6) @storage)) (temp-storage/cleanup! storage))) - (testing "Over max-file-size threshold, aborts query" (mt/with-temporary-setting-values [notification-temp-file-size-max-bytes (* 4 1024)] (let [many-rows 50000 @@ -80,12 +77,10 @@ (is (= 0 (:row_count result))) (is (vector? (get-in result [:data :rows]))) (is (empty? (get-in result [:data :rows]))))) - (testing "Single row" (let [result (run-rff 5 [[42]])] (is (= 1 (:row_count result))) (is (= [[42]] (get-in result [:data :rows]))))) - (testing "Large row count" (let [result (run-rff 100 (for [i (range 150)] [i])) storage (get-in result [:data :rows])] diff --git a/test/metabase/notification/send_test.clj b/test/metabase/notification/send_test.clj index e98a5bd725a5..13040d7cf07e 100644 --- a/test/metabase/notification/send_test.clj +++ b/test/metabase/notification/send_test.clj @@ -67,7 +67,6 @@ {:type :notification-recipient/user :user_id (mt/user->id :rasta)}]} (notification.tu/with-captured-channel-send! (#'notification.send/send-notification-sync! notification-info))))) - (testing "render-notification is called on all handlers with the correct channel and template" (is (=? [{:channel-type (keyword notification.tu/test-channel-type) :notification-payload expected-notification-payload @@ -313,7 +312,6 @@ :status :success :task_details default-task-details} (latest-task-history-entry :channel-send))))) - (testing "retry errors are recorded when the task eventually succeeds" (with-redefs [channel/send! (tu/works-after 2 (constantly nil))] (send!) @@ -328,7 +326,6 @@ [:message :string] [:timestamp :string]]])})} (latest-task-history-entry :channel-send))))) - (testing "retry errors are recorded when the task eventually fails" (with-redefs [channel/send! (tu/works-after 5 (constantly nil))] (send!) @@ -425,19 +422,14 @@ (let [hourly-cron "0 0 * * * ? *" daily-cron "0 0 12 * * ? *" minutely-cron "0 * * * * ? *"] - (testing "hourly schedule" (is (= 3600 (#'notification.send/avg-interval-seconds hourly-cron 5)))) - (testing "daily schedule" (is (= 86400 (#'notification.send/avg-interval-seconds daily-cron 5)))) - (testing "minutely schedule" (is (= 60 (#'notification.send/avg-interval-seconds minutely-cron 5)))))) - (testing "throws assertion error when n < 1" (is (thrown? AssertionError (#'notification.send/avg-interval-seconds "0 0 12 * * ? *" 0)))) - (testing "handles one-off schedules correctly" (with-redefs [notification.send/cron->next-execution-times (fn [_ _] [(t/instant)])] (is (= 10 (#'notification.send/avg-interval-seconds "0 0 12 * * ? *" 5)))))) @@ -451,7 +443,6 @@ :cron_schedule "* * * * * ? *"})] (is (t/before? now deadline)) (is (t/before? deadline (t/plus now (t/seconds 10)))))) - (testing "non-cron subscription types get default deadline" (let [deadline (#'notification.send/subscription->deadline {:type :some-other-type})] (is (t/before? now deadline)) @@ -488,7 +479,6 @@ (test-dispatcher notification) (wait-for-processing 1) (is (= [notification] @sent-notifications)))) - (testing "notifications without IDs are all processed" (reset! sent-notifications []) (test-dispatcher {:test-value "B"}) @@ -496,7 +486,6 @@ (wait-for-processing 2) (is (= 2 (count @sent-notifications))) (is (= #{"B" "C"} (into #{} (map :test-value @sent-notifications))))) - (testing "notifications with same ID are replaced in queue" (reset! sent-notifications []) ;; make the queue busy @@ -510,7 +499,6 @@ :done? (fn [value] (= "E" value)) :interval-ms 10 :timeout-ms 1000})) - (testing "error handling - worker errors don't crash the dispatcher" (reset! sent-notifications []) (let [error-thrown (atom false)] @@ -543,7 +531,6 @@ (#'notification.send/put-notification! queue middle-priority) (#'notification.send/put-notification! queue low-priority) (#'notification.send/put-notification! queue high-priority) - (is (= [high-priority middle-priority low-priority] (for [_ (range 3)] (take-notification! queue))))))) @@ -567,7 +554,6 @@ (#'notification.send/put-notification! queue notification-v1) (#'notification.send/put-notification! queue high-priority) (#'notification.send/put-notification! queue notification-v2) - (is (= [high-priority notification-v2] ;; If deadline is preserved, high-priority should come first since it was added after notification-v1 ;; If deadline was recalculated, notification-v2 would come first due to its minutely schedule @@ -576,32 +562,27 @@ (deftest notification-dedup-priority-test (let [queue (#'notification.send/create-dedup-priority-queue)] - (testing "put and take operations work correctly" (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "A"}) (is (= {:id 1 :payload_type :notification/testing :test-value "A"} (take-notification! queue)))) - (testing "notifications with same ID are replaced in queue" (let [queue (#'notification.send/create-dedup-priority-queue)] (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "A"}) (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "B"}) (is (= {:id 1 :payload_type :notification/testing :test-value "B"} (take-notification! queue))))) - (testing "multiple notifications are processed in order" (let [queue (#'notification.send/create-dedup-priority-queue)] (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "A"}) (#'notification.send/put-notification! queue {:id 2 :payload_type :notification/testing :test-value "B"}) (#'notification.send/put-notification! queue {:id 3 :payload_type :notification/testing :test-value "C"}) - (is (= {:id 1 :payload_type :notification/testing :test-value "A"} (take-notification! queue))) (is (= {:id 2 :payload_type :notification/testing :test-value "B"} (take-notification! queue))) (is (= {:id 3 :payload_type :notification/testing :test-value "C"} (take-notification! queue))))) - (testing "take blocks until notification is available" (let [result (atom nil) ready-latch (java.util.concurrent.CountDownLatch. 1) @@ -615,10 +596,8 @@ ; Put a notification that the thread should receive (#'notification.send/put-notification! queue {:id 42 :payload_type :notification/testing :test-value "X"}) - ; Wait for take to complete (.await take-latch) - (is (= {:id 42 :payload_type :notification/testing :test-value "X"} @result)))))) (deftest blocking-queue-concurrency-test @@ -650,24 +629,18 @@ (log/errorf e "Consumer %s error:" consumer-id)))) _consumers (mapv #(doto (Thread. (fn [] (consumer-fn %))) .start) (range num-consumers)) producers (mapv #(doto (Thread. (fn [] (producer-fn %))) .start) (range num-producers))] - ; Start all producers simultaneously (.countDown producer-latch) - ; Wait for all items to be consumed (is (.await consumer-latch 10000 java.util.concurrent.TimeUnit/MILLISECONDS) "Timed out waiting for consumers to process all items") - ; Wait for all producer threads to complete (doseq [t producers] (.join ^Thread t 5000)) - (testing "all items were processed" (is (= total-items (count @received-items)))) - (testing "each item was processed exactly once" (let [item-ids (map first @received-items)] (is (= (count item-ids) (count (set item-ids)))))) - (testing "work was distributed among consumers" (let [consumer-counts (->> @received-items (map #(get-in % [2 :consumer])) @@ -684,7 +657,6 @@ (assert false)) notification.send/send-notification-sync! (fn [_notification] (swap! noti-count inc))] - (notification.tu/with-card-notification [notification {}] (doseq [_ (range (+ 2 queue-size))] @@ -696,24 +668,20 @@ (deftest blocking-queue-test (let [queue (#'notification.send/->BlockingQueue (java.util.concurrent.ArrayBlockingQueue. 10))] - (testing "put and take operations work correctly" (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "A"}) (is (= {:id 1 :payload_type :notification/testing :test-value "A"} (take-notification! queue)))) - (testing "multiple notifications are processed in order, no dedup" (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "A"}) (#'notification.send/put-notification! queue {:id 1 :payload_type :notification/testing :test-value "B"}) (#'notification.send/put-notification! queue {:id 2 :payload_type :notification/testing :test-value "C"}) - (is (= {:id 1 :payload_type :notification/testing :test-value "A"} (take-notification! queue))) (is (= {:id 1 :payload_type :notification/testing :test-value "B"} (take-notification! queue))) (is (= {:id 2 :payload_type :notification/testing :test-value "C"} (take-notification! queue)))) - (testing "take blocks until notification is available" (let [result (atom nil) ready-latch (java.util.concurrent.CountDownLatch. 1) @@ -727,10 +695,8 @@ ; Put a notification that the thread should receive (#'notification.send/put-notification! queue {:id 42 :payload_type :notification/testing :test-value "X"}) - ; Wait for take to complete (.await take-latch) - (is (= {:id 42 :payload_type :notification/testing :test-value "X"} @result)))))) (deftest notification-dispatcher-graceful-shutdown-test @@ -746,20 +712,17 @@ ;; Wait for the latch to be released before processing (.await processing-latch) (swap! processed-notifications conj notification))] - (testing "notifications are queued and processed during shutdown" (dispatch-fn {:id 1 :payload_type :notification/testing :test-value "A"}) (dispatch-fn {:id 2 :payload_type :notification/testing :test-value "B"}) (dispatch-fn {:id 3 :payload_type :notification/testing :test-value "C"}) (dispatch-fn {:id 4 :payload_type :notification/testing :test-value "D"}) - ;; why "at least 2"? because popping items off the queue is in another thread, it may not have happened yet. (testing "there are at least 2 notifications waiting in the queue" (is (<= 2 (notification.send/queue-size queue)))) (testing "sanity check that notifications were not processed" (is (= 0 (count @processed-notifications)) "No notifications should be processed before latch is released")) - (let [shutdown-fut (future (shutdown-fn 1000))] (.countDown processing-latch) @shutdown-fut @@ -768,7 +731,6 @@ (is (= 4 (count @processed-notifications))) (is (= #{"A" "B" "C" "D"} (into #{} (map :test-value @processed-notifications))))) - (testing "shutdown dispatcher won't accept new items" (is (= ::notification.send/shutdown (dispatch-fn {:id 5 :payload_type :notification/testing :test-value "E"}))) diff --git a/test/metabase/notification/task/send_test.clj b/test/metabase/notification/task/send_test.clj index 820c2f05d298..4228a0cd0c2d 100644 --- a/test/metabase/notification/task/send_test.clj +++ b/test/metabase/notification/task/send_test.clj @@ -68,14 +68,12 @@ (testing "init send notification triggers are idempotent if the subscription doesn't change" (task.notification/init-send-notification-triggers!) (is (= notification-triggers (notification.tu/send-notification-triggers subscription-id)))) - (testing "Re-create triggers if it's not existed" (task/delete-trigger! (TriggerKey. (-> notification-triggers first :key))) (testing "sanity check that the trigger is deleted" (is (empty? (notification.tu/send-notification-triggers subscription-id)))) (task.notification/init-send-notification-triggers!) (is (= notification-triggers (notification.tu/send-notification-triggers subscription-id)))) - (testing "deletes triggers for subscriptions that no longer exist" (let [subscription-id (first (t2/select-pks-vec :model/NotificationSubscription :notification_id (:id notification)))] @@ -99,13 +97,11 @@ notification-triggers (notification.tu/send-notification-triggers subscription-id)] (testing "sanity check that it has triggers to begin with" (is (not-empty notification-triggers))) - (testing "skips triggers for inactive notifications" ;; Deactivate the notification (t2/update! :model/Notification (:id notification) {:active false}) (task.notification/init-send-notification-triggers!) (is (empty? (notification.tu/send-notification-triggers subscription-id)))) - (testing "recreates triggers when notification is reactivated" ;; Reactivate the notification (t2/update! :model/Notification (:id notification) {:active true}) @@ -123,20 +119,16 @@ :cron_schedule "0 0 * 1/1 * ? *"}] []) subscription-id (-> notification models.notification/hydrate-notification :subscriptions first :id)] - (testing "sanity check that trigger exists with initial timezone" (let [triggers (notification.tu/send-notification-triggers subscription-id)] (is (not-empty triggers)) (is (= "UTC" (:timezone (first triggers)))))) - (testing "updates timezone of triggers when report timezone changes" (let [new-timezone "America/Los_Angeles"] ;; Change the report timezone (driver/report-timezone! new-timezone) - ;; Call the function under test (task.notification/update-send-notification-triggers-timezone!) - ;; Verify the timezone was updated (let [updated-triggers (notification.tu/send-notification-triggers subscription-id)] (is (not-empty updated-triggers)) diff --git a/test/metabase/notification/test_util.clj b/test/metabase/notification/test_util.clj index 5344cd1fdbfe..2de624d35257 100644 --- a/test/metabase/notification/test_util.clj +++ b/test/metabase/notification/test_util.clj @@ -149,7 +149,6 @@ {:name default-card-name :dataset_query (mt/mbql-query products {:aggregation [[:count]] :breakout [$category]})} - card)] (do-with-temp-notification {:notification (merge {:payload (assoc notification-card @@ -224,7 +223,6 @@ (with-channel-fixtures (keys channel-type->assert-fn) (let [channel-type->captured-message (with-captured-channel-send! (notification/send-notification! notification))] - (doseq [[channel-type assert-fn] channel-type->assert-fn] (testing (format "chanel-type = %s" channel-type) (assert-fn (get channel-type->captured-message channel-type))))))) diff --git a/test/metabase/parameters/chain_filter_test.clj b/test/metabase/parameters/chain_filter_test.clj index 6ab99d11f297..a707f7a324bb 100644 --- a/test/metabase/parameters/chain_filter_test.clj +++ b/test/metabase/parameters/chain_filter_test.clj @@ -573,7 +573,6 @@ [14 "Caribbean"]] :has_more_values false} (take-n-values 3 (chain-filter-search venues.category_id nil "ar")))))) - (testing "Show me categories containing 'house' that have expensive restaurants" (is (= {:values [[67 "Steakhouse"]] :has_more_values false} @@ -739,46 +738,39 @@ (testing "`false` for field has values less than [[field-values/*total-max-length*]] threshold" (is (= false (:has_more_values (chain-filter categories.name {}))))) - (testing "`true` if the limit option is less than the count of values of fieldvalues" (is (true? (:has_more_values (chain-filter categories.name {} :limit 1))))) (testing "`false` if the limit option is greater the count of values of fieldvalues" (is (= false (:has_more_values (chain-filter categories.name {} :limit Integer/MAX_VALUE)))))) - (testing "`true` if the values of a field exceeds our [[field-values/*total-max-length*]] limit" (with-clean-field-values-for-field! (mt/id :categories :name) (binding [field-values/*total-max-length* 10] (is (true? (:has_more_values (chain-filter categories.name {})))))))) - (testing "with contraints" (with-clean-field-values-for-field! (mt/id :categories :name) (testing "`false` for field has values less than [[field-values/*total-max-length*]] threshold" (is (= false (:has_more_values (chain-filter categories.name {venues.price 4}))))) - (testing "`true` if the limit option is less than the count of values of fieldvalues" (is (true? (:has_more_values (chain-filter categories.name {venues.price 4} :limit 1))))) (testing "`false` if the limit option is greater the count of values of fieldvalues" (is (= false (:has_more_values (chain-filter categories.name {venues.price 4} :limit Integer/MAX_VALUE)))))) - (with-clean-field-values-for-field! (mt/id :categories :name) (testing "`true` if the values of a field exceeds our [[field-values/*total-max-length*]] limit" (binding [field-values/*total-max-length* 10] (is (true? (:has_more_values (chain-filter categories.name {venues.price 4}))))))))) - (testing "for non-cached fields" (testing "with contraints" (with-clean-field-values-for-field! (mt/id :venues :latitude) (testing "`false` if we don't specify limit" (is (= false (:has_more_values (chain-filter venues.latitude {venues.price 4}))))) - (testing "`true` if the limit is less than the number of values the field has" (is (true? (:has_more_values (chain-filter venues.latitude {venues.price 4} :limit 1)))))))))) @@ -815,7 +807,6 @@ :rhs {:table $$users, :field %users.id}}] (->> (#'chain-filter/find-joins (mt/id) $$messages $$users) (sort-by (comp :field :lhs)))))) - (try (t2/update! :model/Field {:id %messages.receiver_id} {:active false}) (testing "check that it switches to sender only once receiver is inactive" @@ -824,7 +815,6 @@ (#'chain-filter/find-joins (mt/id) $$messages $$users)))) (finally (t2/update! :model/Field {:id %messages.receiver_id} {:active true}))) - (try (t2/update! :model/Field {:id %messages.sender_id} {:active false}) (testing "check that it switches to receiver only once sender is inactive" @@ -833,7 +823,6 @@ (#'chain-filter/find-joins (mt/id) $$messages $$users)))) (finally (t2/update! :model/Field {:id %messages.sender_id} {:active true}))) - ;; mark field (t2/update! :model/Field {:id %users.id} {:active false}) (testing "there are no connections when PK is inactive" diff --git a/test/metabase/parameters/custom_values_test.clj b/test/metabase/parameters/custom_values_test.clj index 34c34c0e3bea..edeed3bad824 100644 --- a/test/metabase/parameters/custom_values_test.clj +++ b/test/metabase/parameters/custom_values_test.clj @@ -1,6 +1,7 @@ (ns metabase.parameters.custom-values-test (:require [clojure.test :refer :all] + [metabase.lib.core :as lib] [metabase.parameters.custom-values :as custom-values] [metabase.test :as mt] [metabase.test.fixtures :as fixtures])) @@ -35,6 +36,39 @@ [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} (mt/id :venues :name)] {:query-string "bakery"})))))))))) +(deftest ^:parallel with-label-field-test + (testing "providing a label-field adds a second breakout so each row is a [value label] pair" + (binding [custom-values/*max-rows* 3] + (mt/with-temp + [:model/Card card (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :type :question + :table_id (mt/id :venues)})] + (is (= {:has_more_values true + :values [[1 "Red Medicine"] + [2 "Stout Burgers & Beers"] + [3 "The Apple Pan"]]} + (custom-values/values-from-card + card + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} (mt/id :venues :id)] + {:label-field [:field {:lib/uuid "00000000-0000-0000-0000-000000000001"} (mt/id :venues :name)]}))))))) + +(deftest ^:parallel search-by-label-field-test + (testing "query-string with a label-field filters by the label, not the value" + (mt/with-temp + [:model/Card card (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :type :question + :table_id (mt/id :venues)})] + (let [{:keys [values]} (custom-values/values-from-card + card + [:field {:lib/uuid "00000000-0000-0000-0000-000000000000"} (mt/id :venues :id)] + {:query-string "bakery" + :label-field [:field {:lib/uuid "00000000-0000-0000-0000-000000000001"} + (mt/id :venues :name)]})] + (is (seq values)) + (is (every? (fn [[_id label]] (re-find #"(?i)bakery" label)) values)))))) + (deftest ^:parallel with-mbql-card-test-2 (testing "source card is a model" ; Models are opaque, so this sees the post-aggregation columns. (binding [custom-values/*max-rows* 3] @@ -335,6 +369,134 @@ nil (constantly mock-default-result)))))))))) +(deftest ^:parallel parameter->values-with-label-field-test + ;; bind to an admin to bypass the permissions check + (mt/with-current-user (mt/user->id :crowberto) + (testing "a card source with a label_field returns [value label] pairs through parameter->values" + (binding [custom-values/*max-rows* 3] + (mt/with-temp + [:model/Card card (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :type :question + :table_id (mt/id :venues)})] + (is (= {:has_more_values true + :values [[1 "Red Medicine"] + [2 "Stout Burgers & Beers"] + [3 "The Apple Pan"]]} + (custom-values/parameter->values + {:name "Card as source" + :slug "card" + :id "_CARD_" + :type :category + :values_source_type :card + :values_source_config {:card_id (:id card) + :value_field (mt/$ids $venues.id) + :label_field (mt/$ids $venues.name)}} + nil + (fn [] (throw (ex-info "Shouldn't call this function" {}))))))))))) + +(deftest ^:parallel parameter-remapped-value-card-test + (mt/with-current-user (mt/user->id :crowberto) + (testing "a card source with a label_field remaps a single value to a [value label] pair" + (mt/with-temp + [:model/Card card (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :type :question + :table_id (mt/id :venues)})] + (is (= [1 "Red Medicine"] + (custom-values/parameter-remapped-value + {:name "Card as source" + :slug "card" + :id "_CARD_" + :type :category + :values_source_type :card + :values_source_config {:card_id (:id card) + :value_field (mt/$ids $venues.id) + :label_field (mt/$ids $venues.name)}} + 1 + (fn [] (throw (ex-info "Shouldn't call this function" {})))))))) + (testing "a card source without a label_field has no remapped value" + (mt/with-temp + [:model/Card card (merge (mt/card-with-source-metadata-for-query (mt/mbql-query venues)) + {:database_id (mt/id) + :type :question + :table_id (mt/id :venues)})] + (is (nil? (custom-values/parameter-remapped-value + {:name "Card as source" + :slug "card" + :id "_CARD_" + :type :category + :values_source_type :card + :values_source_config {:card_id (:id card) + :value_field (mt/$ids $venues.id)}} + 1 + (fn [] (throw (ex-info "Shouldn't call this function" {})))))))))) + +(deftest ^:parallel values-from-card-external-remapping-test + (testing "remapped breakouts are stripped from the result, leaving only the requested columns in the requested order" + (mt/dataset test-data + (mt/with-column-remappings [orders.user_id people.name + orders.product_id products.title] + (binding [custom-values/*max-rows* 3] + (mt/with-temp + [:model/Card card (mt/card-with-source-metadata-for-query (mt/mbql-query orders))] + (let [user-id-ref (lib/ensure-uuid [:field {} (mt/id :orders :user_id)]) + people-name-ref (lib/ensure-uuid [:field {} (mt/id :people :name)]) + product-title-ref (lib/ensure-uuid [:field {} (mt/id :products :title)])] + (testing "value-field is a remapped FK with no label" + (is (= {:has_more_values true + :values [[2210] [624] [276]]} + (custom-values/values-from-card card user-id-ref)))) + (testing "value-field is a remapped FK, label-field is its remap target" + (is (= {:has_more_values true + :values [[2210 "Abbey Satterfield"] + [624 "Abbie Parisian"] + [276 "Abbie Ryan"]]} + (custom-values/values-from-card card user-id-ref {:label-field people-name-ref})))) + (testing "value-field and label-field are remap targets reached via different FKs" + (is (= {:has_more_values true + :values [["Abbey Satterfield" "Aerodynamic Leather Toucan"] + ["Abbey Satterfield" "Awesome Plastic Watch"] + ["Abbey Satterfield" "Enormous Cotton Pants"]]} + (custom-values/values-from-card card people-name-ref {:label-field product-title-ref}))))))))))) + +(deftest ^:parallel parameter-remapped-value-external-remapping-test + (testing "parameter-remapped-value returns the [value label] pair for a single value when breakouts are remapped" + (mt/dataset test-data + (mt/with-current-user (mt/user->id :crowberto) + (mt/with-column-remappings [orders.user_id people.name + orders.product_id products.title] + (binding [custom-values/*max-rows* 3] + (mt/with-temp + [:model/Card card (mt/card-with-source-metadata-for-query (mt/mbql-query orders))] + (let [raise (fn [] (throw (ex-info "Shouldn't call this function" {})))] + (testing "value-field is a remapped FK, label-field is its remap target" + (is (= [2210 "Abbey Satterfield"] + (custom-values/parameter-remapped-value + {:name "Card as source" + :slug "card" + :id "_CARD_" + :type :category + :values_source_type :card + :values_source_config {:card_id (:id card) + :value_field (mt/$ids $orders.user_id) + :label_field (mt/$ids $people.name)}} + 2210 + raise)))) + (testing "value-field and label-field are remap targets reached via different FKs" + (is (= ["Abbey Satterfield" "Aerodynamic Leather Toucan"] + (custom-values/parameter-remapped-value + {:name "Card as source" + :slug "card" + :id "_CARD_" + :type :category + :values_source_type :card + :values_source_config {:card_id (:id card) + :value_field (mt/$ids $orders.user_id->people.name) + :label_field (mt/$ids $orders.product_id->products.title)}} + "Abbey Satterfield" + raise)))))))))))) + (deftest ^:parallel order-by-aggregation-fields-test (testing "Values could be retrieved for queries containing ordering by aggregation (#46369)" (doseq [model? [true false]] diff --git a/test/metabase/parameters/dashboard_test.clj b/test/metabase/parameters/dashboard_test.clj index 0195c80e4a9a..dc33e005f2ca 100644 --- a/test/metabase/parameters/dashboard_test.clj +++ b/test/metabase/parameters/dashboard_test.clj @@ -118,12 +118,10 @@ qp.perms/*param-values-query* true] (let [dashboard (t2/select-one :model/Dashboard :id dashboard-id) parameter (first (:parameters dashboard))] - (testing "Should get remapped values for parameter with multiple FK fields pointing to same PK" (let [remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] (is (some? remapped-values) "Should get remapped values for multi-field FK scenario") - (when remapped-values (is (= [1 "Rustic Paper Wallet"] @@ -172,7 +170,6 @@ ;; Mimicks the API endpoint (required): (binding [qp.perms/*param-values-query* true] (let [remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] - (is (= [1 "Rustic Paper Wallet"] remapped-values) "we still get the remapped value")))))) @@ -220,10 +217,10 @@ (binding [api/*current-user-id* (mt/user->id :rasta)] (let [dashboard (t2/select-one :model/Dashboard :id dashboard-id) parameter (first (:parameters dashboard))] - ;; Mimicks the API endpoint (required): + ;; Mimicks the API endpoint (required): (binding [qp.perms/*param-values-query* true] - ;; Important to check all the mappings here because sometimes they match up by coincidence - ;; and pass even when the bug is still present. + ;; Important to check all the mappings here because sometimes they match up by coincidence + ;; and pass even when the bug is still present. (let [expected (into {} (mt/rows (mt/process-query (mt/mbql-query users)))) actual (into {} (map (fn [id] (parameters.dashboard/dashboard-param-remapped-value @@ -260,7 +257,6 @@ :parameter_mappings [{:card_id reviews-card-id :parameter_id "p1" :target ["dimension" ["field" reviews-product-id-field-id nil]]}]}] - (testing "Scenario 1: FK1→A, FK2→B should return raw value (no common remapping)" (mt/with-column-remappings [orders.product_id products.title reviews.product_id products.category ; Different remapping @@ -271,7 +267,6 @@ remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] (is (= [1] remapped-values) "Should return raw value when FK remappings conflict"))))) - (testing "Scenario 2: FK1→A, FK2→A, PK→C should return A (common FK remapping wins)" (mt/with-column-remappings [orders.product_id products.title reviews.product_id products.title ; Same remapping as FK1 @@ -282,9 +277,8 @@ remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] (is (= [1 "Rustic Paper Wallet"] remapped-values) "Should return common FK remapping when FKs agree"))))) - (testing "Scenario 3: FK1→∅, FK2→A should return raw value (no consensus among FKs)" - ;; Set up FK2 with remapping, but leave FK1 without remapping, PK with different remapping + ;; Set up FK2 with remapping, but leave FK1 without remapping, PK with different remapping (mt/with-column-remappings [reviews.product_id products.title products.id products.category] (binding [api/*current-user-id* (mt/user->id :rasta)] @@ -293,16 +287,14 @@ remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] (is (= [1] remapped-values) "Should return raw value when only some FKs have remapping"))))) - (testing "Scenario 4: No remappings at all should return raw value" - ;; No remappings set up + ;; No remappings set up (binding [api/*current-user-id* (mt/user->id :rasta)] (let [dashboard (t2/select-one :model/Dashboard :id dashboard-id) parameter (first (:parameters dashboard)) remapped-values (parameters.dashboard/dashboard-param-remapped-value dashboard (:id parameter) 1)] (is (= [1] remapped-values) "Should return only raw value when no remappings are configured")))) - (testing "Scenario 5: Only PK remapping should return raw value (PK ignored)" (mt/with-column-remappings [products.id products.title] (binding [api/*current-user-id* (mt/user->id :rasta)] @@ -318,7 +310,6 @@ (let [orders-product-id-field-id (mt/id :orders :product_id) reviews-product-id-field-id (mt/id :reviews :product_id) products-title-field-id (mt/id :products :title)] - (testing "When both FK fields remap to the same target" (mt/with-column-remappings [orders.product_id products.title reviews.product_id products.title] @@ -326,29 +317,24 @@ (#'parameters.dashboard/find-common-remapping-target [orders-product-id-field-id reviews-product-id-field-id])) "Should return common target when both FKs remap to same field"))) - (testing "When FK fields remap to different targets" (mt/with-column-remappings [orders.product_id products.title reviews.product_id products.category] (is (nil? (#'parameters.dashboard/find-common-remapping-target [orders-product-id-field-id reviews-product-id-field-id])) "Should return nil when FKs remap to different fields"))) - (testing "When only one FK field has remapping" (mt/with-column-remappings [orders.product_id products.title] (is (nil? (#'parameters.dashboard/find-common-remapping-target [orders-product-id-field-id reviews-product-id-field-id])) "Should return nil when only one FK has remapping (no consensus)"))) - (testing "When no FK fields have remapping" (is (nil? (#'parameters.dashboard/find-common-remapping-target [orders-product-id-field-id reviews-product-id-field-id])) "Should return nil when no FKs have remapping")) - (testing "With empty field list" (is (nil? (#'parameters.dashboard/find-common-remapping-target [])) "Should return nil for empty field list")) - (testing "With single field that has remapping" (mt/with-column-remappings [orders.product_id products.title] (is (= products-title-field-id diff --git a/test/metabase/parameters/field_test.clj b/test/metabase/parameters/field_test.clj index d910f9f75c3a..d3af0d5c44f1 100644 --- a/test/metabase/parameters/field_test.clj +++ b/test/metabase/parameters/field_test.clj @@ -83,7 +83,6 @@ (t2/update! :model/Field (mt/id :users :name) {:semantic_type :type/FK :has_field_values "search" :fk_target_field_id (mt/id :categories :name)}) - (is (= [["African"]] (parameters.field/search-values (t2/select-one :model/Field (mt/id :users :name)) (t2/select-one :model/Field (mt/id :users :name)) diff --git a/test/metabase/parameters/params_test.clj b/test/metabase/parameters/params_test.clj index ad182b13e31e..008129f48d7b 100644 --- a/test/metabase/parameters/params_test.clj +++ b/test/metabase/parameters/params_test.clj @@ -24,14 +24,12 @@ (-> (t2/select-one [:model/Field :name :table_id :semantic_type], :id (mt/id :venues :id)) (t2/hydrate :name_field) mt/derecordize)))) - (testing "make sure it works for multiple fields efficiently. Should only require one DB call to hydrate many Fields" (let [venues-fields (t2/select :model/Field :table_id (mt/id :venues))] (t2/with-call-count [call-count] (t2/hydrate venues-fields :name_field) (is (= 1 (call-count)))))) - (testing "It shouldn't hydrate for Fields that aren't PKs" (is (= {:name "PRICE" :table_id (mt/id :venues) @@ -40,7 +38,6 @@ (-> (t2/select-one [:model/Field :name :table_id :semantic_type], :id (mt/id :venues :price)) (t2/hydrate :name_field) mt/derecordize)))) - (testing "Or if it *is* a PK, but no name Field is available for that Table, it shouldn't hydrate" (is (= {:name "ID" :table_id (mt/id :checkins) @@ -49,7 +46,6 @@ (-> (t2/select-one [:model/Field :name :table_id :semantic_type], :id (mt/id :checkins :id)) (t2/hydrate :name_field) mt/derecordize)))) - (testing "Inactive Entity Name fields should not be hydrated (#65207)" (let [name-field-id (mt/id :venues :name)] (try @@ -202,7 +198,6 @@ (is (= {"11111111" #{(mt/id :venues :id)} "aaaaaaaa" #{}} (#'params/card->template-tag-id->field-ids card)))) - (testing "card->template-tag-field-ids" (is (= #{(mt/id :venues :id)} (params/card->template-tag-field-ids card)))))) diff --git a/test/metabase/parameters/shared_test.cljc b/test/metabase/parameters/shared_test.cljc index d73195beaf1c..0c72529e8bdb 100644 --- a/test/metabase/parameters/shared_test.cljc +++ b/test/metabase/parameters/shared_test.cljc @@ -148,7 +148,7 @@ {"foo" {:type :string/= :value ""}} "" - ;; Characters in the original text are not escaped + ;; Characters in the original text are not escaped "_*{{foo}}*_" {"foo" {:type :string/= :value "*bar*"}} "_**bar**_"))) @@ -401,7 +401,7 @@ :default "Q1-2021" :type "date/quarter-year", :sectionId "date"} - ;; Filter without default, should not be included in subscription + ;; Filter without default, should not be included in subscription {:name "Product title contains", :slug "product_title_contains", :id "acd0dfab", @@ -410,7 +410,6 @@ (testing "If a filter has multiple values, they are concatenated into a comma-separated string" (is (= "CA, NY, and NJ" (params/value-string (first parameters) "en")))) - (testing "If a filter has a single default value, it is formatted appropriately" (is (= "Q1, 2021" (params/value-string (second parameters) "en")))))) diff --git a/test/metabase/permissions/models/collection/graph_test.clj b/test/metabase/permissions/models/collection/graph_test.clj index e8ca7618bca3..d5961eae9f96 100644 --- a/test/metabase/permissions/models/collection/graph_test.clj +++ b/test/metabase/permissions/models/collection/graph_test.clj @@ -188,7 +188,6 @@ (u/the-id (perms-group/admin)) {:root :write, :COLLECTION :write}}} (replace-collection-ids collection (graph :collections [collection])))) (is (= 1 (c-perm-revision/latest-id))))))) - (testing "can we give them *write* perms?" (mt/with-non-admin-groups-no-root-collection-perms (mt/with-temp [:model/Collection collection] @@ -228,7 +227,6 @@ :groups {(u/the-id (perms-group/admin)) {:root :write} (u/the-id new-group) {:root :read}}} (graph :groups [new-group]))))))) - (testing "How about granting *write* permissions for the Root Collection?" (mt/with-temp [:model/PermissionsGroup new-group] (clear-graph-revisions!) @@ -262,7 +260,6 @@ (is (= {:revision 0 :groups {(u/the-id (perms-group/admin)) {:root :write}}} (graph :clear-revisions? true))))) - (testing "Make sure descendants of Personal Collections do not come back as part of the graph either..." (clear-graph-revisions!) (mt/with-non-admin-groups-no-root-collection-perms @@ -278,16 +275,13 @@ path [:groups (u/the-id (perms-group/all-users)) lucky-personal-collection-id]] (mt/throw-if-called! graph/update-group-permissions! (graph/update-graph! (assoc-in (graph :clear-revisions? true) path :read))) - (testing "double-check that the graph is unchanged" (is (= {:revision 0 :groups {(u/the-id (perms-group/admin)) {:root :write}}} (graph)))) - (testing "No revision should have been saved" (is (= 0 (c-perm-revision/latest-id))))))) - (testing "Make sure you can't be sneaky and edit descendants of Personal Collections either." (mt/with-temp [:model/Collection collection {:location (lucky-collection-children-location)}] (let [lucky-personal-collection-id (u/the-id (collection/user->personal-collection (mt/user->id :lucky)))] @@ -397,11 +391,9 @@ (update-graph-and-wait! (assoc before :groups {group-id {default-ab :write, currency-ab :write}})) (is (= {"Default A" :read, "Default A -> B" :write} (nice-graph (graph/graph)))) - (testing "Updates to Collections in other namespaces should be ignored" (is (= {"Currency A" :read, "Currency A -> B" :read} (nice-graph (graph/graph :currency))))) - (testing "A CollectionPermissionGraphRevision recording the *changes* should be saved (sparse before/after)" (is (malli= [:map [:id ms/PositiveInt] @@ -444,11 +436,9 @@ @(graph/update-graph! :currency (assoc (graph/graph) :groups {group-id {default-a :write, currency-a :write}}) false) (is (= {"Currency A" :write, "Currency A -> B" :read} (nice-graph (graph/graph :currency)))) - (testing "Updates to Collections in other namespaces should be ignored" (is (= {"Default A" :read, "Default A -> B" :write} (nice-graph (graph/graph))))) - (testing "A CollectionPermissionGraphRevision recording the *changes* should be saved (sparse before/after)" (is (malli= [:map [:id ms/PositiveInt] @@ -493,11 +483,9 @@ (update-graph-and-wait! (assoc (graph/graph) :groups {group-id {:root :read}})) (is (= {:root :read, "Default A" :read, "Default A -> B" :write} (nice-graph (graph/graph)))) - (testing "Shouldn't affect Root Collection perms for non-default namespaces (sparse - no :root entry)" (is (= {"Currency A" :write, "Currency A -> B" :read} (nice-graph (graph/graph :currency))))) - (testing "A CollectionPermissionGraphRevision recording the *changes* should be saved (sparse - no :root in before)" (is (=? {:before {:namespace nil :groups {}} @@ -540,11 +528,9 @@ (update-graph-and-wait! :currency (assoc (graph/graph :currency) :groups {group-id {:root :write}})) (is (= {:root :write, "Currency A" :write, "Currency A -> B" :read} (nice-graph (graph/graph :currency)))) - (testing "Shouldn't affect Root Collection perms for default namespace" (is (= {:root :read, "Default A" :read, "Default A -> B" :write} (nice-graph (graph/graph))))) - (testing "A CollectionPermissionGraphRevision recording the *changes* should be saved (sparse - no :root in before)" (is (=? {:before {:namespace "currency" :groups {}} diff --git a/test/metabase/permissions/models/data_permissions_test.clj b/test/metabase/permissions/models/data_permissions_test.clj index b252bd051218..fe333edca233 100644 --- a/test/metabase/permissions/models/data_permissions_test.clj +++ b/test/metabase/permissions/models/data_permissions_test.clj @@ -47,12 +47,10 @@ (is (= :no (perm-value :perms/create-queries))) (data-perms/set-database-permission! group-id database-id :perms/create-queries :query-builder) (is (= :query-builder (perm-value :perms/create-queries)))) - (testing "`set-database-permission!` sets native query permissions to :no if data access is set to :blocked" (data-perms/set-database-permission! group-id database-id :perms/view-data :blocked) (is (= :blocked (perm-value :perms/view-data))) (is (= :no (perm-value :perms/create-queries)))) - (testing "A database-level permission cannot be set to an invalid value" (is (thrown-with-msg? ExceptionInfo @@ -81,11 +79,9 @@ (is (= :no (create-queries-perm-value table-id-1))) (is (= :query-builder (create-queries-perm-value table-id-2))) (is (= :no (create-queries-perm-value table-id-3)))) - (testing "`set-table-permissions!` can set individual table permissions passed in as the full tables" (data-perms/set-table-permissions! group-id :perms/create-queries {table-1 :query-builder}) (is (= :query-builder (create-queries-perm-value table-id-1)))) - (testing "`set-table-permission!` coalesces table perms to a DB-level value if they're all the same" (data-perms/set-table-permissions! group-id :perms/create-queries {table-id-1 :no table-id-2 :no}) @@ -93,7 +89,6 @@ (is (nil? (create-queries-perm-value table-id-1))) (is (nil? (create-queries-perm-value table-id-2))) (is (nil? (create-queries-perm-value table-id-3)))) - (testing "`set-table-permission!` breaks table perms out again if any are modified" (data-perms/set-table-permissions! group-id :perms/create-queries {table-id-2 :query-builder table-id-3 :no}) @@ -101,36 +96,30 @@ (is (= :no (create-queries-perm-value table-id-1))) (is (= :query-builder (create-queries-perm-value table-id-2))) (is (= :no (create-queries-perm-value table-id-3)))) - (testing "A non table-level permission cannot be set" (is (thrown-with-msg? ExceptionInfo #"Permission type :perms/manage-database cannot be set on tables." (data-perms/set-table-permissions! group-id :perms/manage-database {table-id-1 :yes})))) - (testing "A table-level permission cannot be set to an invalid value" (is (thrown-with-msg? ExceptionInfo #"Invalid permission value :invalid for permission type :perms/create-queries" (data-perms/set-table-permissions! group-id :perms/create-queries {table-id-1 :invalid})))) - (testing "A table-level permission can be set to :block" (is (= nil (data-perms/set-table-permissions! group-id :perms/view-data {table-id-1 :blocked})))) - (testing "Table-level permissions can only be set in bulk for tables in the same database" (is (thrown-with-msg? ExceptionInfo #"All tables must belong to the same database." (data-perms/set-table-permissions! group-id :perms/create-queries {table-id-3 :query-builder table-id-4 :query-builder})))) - (testing "Setting block permissions at the database level clears table-level query query perms" (data-perms/set-database-permission! group-id database-id :perms/view-data :blocked) (is (= :no (create-queries-perm-value nil))) (is (nil? (create-queries-perm-value table-id-1))) (is (nil? (create-queries-perm-value table-id-2))) (is (nil? (create-queries-perm-value table-id-3)))) - (testing "Setting view-data to :blocked for tables also sets create-queries and download-results to :no" (let [download-results-perm-value (fn [table-id] (t2/select-one-fn :perm_value :model/DataPermissions :db_id database-id @@ -144,14 +133,11 @@ table-id-2 :query-builder}) (data-perms/set-table-permissions! group-id :perms/download-results {table-id-1 :one-million-rows table-id-2 :one-million-rows}) - ;; Now set view-data to :blocked for table-id-1 only (data-perms/set-table-permissions! group-id :perms/view-data {table-id-1 :blocked}) - ;; Verify that create-queries and download-results are set to :no for table-id-1 (is (= :no (create-queries-perm-value table-id-1))) (is (= :no (download-results-perm-value table-id-1))) - ;; Verify that table-id-2 permissions are unchanged (is (= :query-builder (create-queries-perm-value table-id-2))) (is (= :one-million-rows (download-results-perm-value table-id-2))))))))) @@ -171,14 +157,11 @@ (data-perms/set-database-permission! group-id-1 database-id-1 :perms/manage-database :yes) (data-perms/set-database-permission! group-id-2 database-id-1 :perms/manage-database :no) (is (= :yes (data-perms/database-permission-for-user user-id :perms/manage-database database-id-1)))) - (testing "`database-permission-for-user` falls back to the least permissive value if no value exists for the user" (t2/delete! :model/DataPermissions :db_id database-id-2) (is (= :no (data-perms/database-permission-for-user user-id :perms/manage-database database-id-2)))) - (testing "Admins always have the most permissive value, regardless of group membership" (is (= :yes (data-perms/database-permission-for-user (mt/user->id :crowberto) :perms/manage-database database-id-2))))) - (testing "caching works as expected" (binding [api/*current-user-id* user-id] (mt/with-restored-data-perms-for-groups! [group-id-1 group-id-2] @@ -192,7 +175,6 @@ (t2/with-call-count [call-count] (is (= :yes (data-perms/database-permission-for-user user-id :perms/manage-database database-id-1))) (is (zero? (call-count))))) - ;; Fetching perms for a different DB is a cache miss (t2/with-call-count [call-count] (is (= :no (data-perms/database-permission-for-user user-id :perms/manage-database database-id-2))) @@ -217,14 +199,11 @@ (data-perms/set-table-permission! group-id-2 table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! (perms-group/all-users) table-id-1 :perms/create-queries :no) (is (= :query-builder (data-perms/table-permission-for-user user-id :perms/create-queries database-id table-id-1)))) - (testing "`table-permission-for-user` falls back to the least permissive value if no value exists for the user" (t2/delete! :model/DataPermissions :db_id database-id) (is (= :no (data-perms/table-permission-for-user user-id :perms/create-queries database-id table-id-2)))) - (testing "Admins always have the most permissive value, regardless of group membership" (is (= :query-builder-and-native (data-perms/table-permission-for-user (mt/user->id :crowberto) :perms/create-queries database-id table-id-2)))) - (mt/with-restored-data-perms-for-groups! [group-id-1 group-id-2] (testing "caching works as expected" (binding [api/*current-user-id* user-id] @@ -300,7 +279,6 @@ {:perms/view-data :blocked :perms/create-queries :no}} (data-perms/permissions-for-user user-id-1)))) - (testing "Perms from multiple groups are coalesced" (data-perms/set-database-permission! group-id-2 database-id-1 :perms/view-data :unrestricted) (data-perms/set-database-permission! group-id-2 database-id-1 :perms/create-queries :no) @@ -314,7 +292,6 @@ {:perms/view-data :unrestricted :perms/create-queries :query-builder-and-native}} (data-perms/permissions-for-user user-id-1)))) - (testing "Table-level perms are included if they're more permissive than any database-level perms" (data-perms/set-table-permission! group-id-1 table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! group-id-1 table-id-2 :perms/create-queries :query-builder) @@ -323,14 +300,12 @@ {:perms/create-queries {table-id-1 :no table-id-2 :query-builder}}} (data-perms/permissions-for-user user-id-1)))) - (testing "Table-level perms are not included if a database-level perm is more permissive" (data-perms/set-database-permission! group-id-2 database-id-1 :perms/create-queries :query-builder-and-native) (is (partial= {database-id-1 {:perms/create-queries :query-builder-and-native}} (data-perms/permissions-for-user user-id-1)))) - (testing "Admins always have full permissions" (data-perms/set-database-permission! group-id-1 database-id-1 :perms/view-data :blocked) (data-perms/set-database-permission! group-id-1 database-id-1 :perms/create-queries :no) @@ -377,7 +352,6 @@ group-id-2 {database-id-1 {:perms/view-data :legacy-no-self-service}}} (data-perms.graph/data-permissions-graph)))) - (testing "Additional data permissions are included when set" (data-perms/set-table-permission! group-id-1 table-id-3 :perms/download-results :one-million-rows) (data-perms/set-table-permission! group-id-1 table-id-1 :perms/manage-table-metadata :yes) @@ -392,7 +366,6 @@ {table-id-3 :one-million-rows}} :perms/manage-database :yes}}} (data-perms.graph/data-permissions-graph)))) - (testing "Data permissions graph can be filtered by group ID, database ID, and permission type" (is (= {group-id-1 {database-id-1 {:perms/view-data @@ -411,7 +384,6 @@ :perms/transforms :no :perms/create-queries :no}}} (data-perms.graph/data-permissions-graph :group-id group-id-1))) - (is (= {group-id-1 {database-id-1 {:perms/view-data {"PUBLIC" @@ -423,7 +395,6 @@ {table-id-1 :yes}}}}} (data-perms.graph/data-permissions-graph :group-id group-id-1 :db-id database-id-1))) - (is (= {group-id-1 {database-id-1 {:perms/view-data {"PUBLIC" @@ -533,64 +504,52 @@ ;; Clear the default permissions for all groups (doseq [group-id [group-id-1 group-id-2 group-id-3]] (t2/delete! :model/DataPermissions :group_id group-id)) - (testing "Returns most permissive permission when user has different levels across groups" ;; Group 1: no permissions (least permissive) (data-perms/set-table-permission! group-id-1 table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! group-id-1 table-id-2 :perms/create-queries :no) (data-perms/set-table-permission! group-id-1 table-id-3 :perms/create-queries :no) - ;; Group 2: query-builder permissions (medium permissive) (data-perms/set-table-permission! group-id-2 table-id-1 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id-2 table-id-2 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id-2 table-id-3 :perms/create-queries :no) - ;; Group 3: native permissions (most permissive) (data-perms/set-table-permission! group-id-3 table-id-1 :perms/create-queries :query-builder-and-native) (data-perms/set-table-permission! group-id-3 table-id-2 :perms/create-queries :no) (data-perms/set-table-permission! group-id-3 table-id-3 :perms/create-queries :no) - ;; Should return the most permissive permission found across all tables and groups (is (= :query-builder-and-native (data-perms/most-permissive-database-permission-for-user user-id :perms/create-queries database-id)))) - (testing "Coalesces permissions correctly for :perms/view-data" ;; Group 1: blocked for all tables (data-perms/set-table-permission! group-id-1 table-id-1 :perms/view-data :blocked) (data-perms/set-table-permission! group-id-1 table-id-2 :perms/view-data :blocked) (data-perms/set-table-permission! group-id-1 table-id-3 :perms/view-data :blocked) - ;; Group 2: unrestricted for one table (data-perms/set-table-permission! group-id-2 table-id-1 :perms/view-data :unrestricted) (data-perms/set-table-permission! group-id-2 table-id-2 :perms/view-data :blocked) (data-perms/set-table-permission! group-id-2 table-id-3 :perms/view-data :blocked) - ;; Group 3: legacy-no-self-service for remaining tables (data-perms/set-table-permission! group-id-3 table-id-1 :perms/view-data :blocked) (data-perms/set-table-permission! group-id-3 table-id-2 :perms/view-data :legacy-no-self-service) (data-perms/set-table-permission! group-id-3 table-id-3 :perms/view-data :legacy-no-self-service) - ;; Should return :unrestricted (most permissive) as per coalesce logic (is (= :unrestricted (data-perms/most-permissive-database-permission-for-user user-id :perms/view-data database-id)))) - (testing "Returns correct permission when all groups have same level" ;; All groups have query-builder permission (data-perms/set-table-permission! group-id-1 table-id-1 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id-2 table-id-1 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id-3 table-id-1 :perms/create-queries :query-builder) - (is (= :query-builder (data-perms/most-permissive-database-permission-for-user user-id :perms/create-queries database-id)))) - (testing "Returns least permissive value when no permissions are granted" ;; Remove all permissions (doseq [group-id [group-id-1 group-id-2 group-id-3]] (t2/delete! :model/DataPermissions :group_id group-id)) - (is (= :no (data-perms/most-permissive-database-permission-for-user user-id :perms/create-queries database-id)))))))) @@ -614,7 +573,6 @@ :group_id group-id :perm_type :perms/view-data))) (t2/delete! :model/Database :id new-db-id)) - (data-perms/set-database-permission! group-id db-id-1 :perms/view-data :legacy-no-self-service) (let [new-db-id (t2/insert-returning-pk! :model/Database {:name "Test" :engine "h2" :details "{}"})] (is (= :unrestricted (t2/select-one-fn :perm_value @@ -623,7 +581,6 @@ :group_id group-id :perm_type :perms/view-data))) (t2/delete! :model/Database :id new-db-id)) - (testing "A new database gets `unrestricted` data perms on OSS even if a group has `blocked` perms for a DB" (mt/with-premium-features #{} (data-perms/set-database-permission! group-id db-id-2 :perms/view-data :blocked) @@ -633,7 +590,6 @@ :db_id new-db-id :group_id group-id :perm_type :perms/view-data)))))))) - (t2/delete! :model/DataPermissions :group_id group-id) (testing "Query permissions... " (testing "A new database gets `query-builder-and-native` query permissions if a group only has `query-builder-and-native` for other databases" @@ -645,7 +601,6 @@ :group_id group-id :perm_type :perms/create-queries))) (t2/delete! :model/Database :id new-db-id))) - (testing "A new database gets `query-builder` query permissions if a group has `query-builder` for any database" (data-perms/set-database-permission! group-id db-id-2 :perms/create-queries :query-builder) (let [new-db-id (t2/insert-returning-pk! :model/Database {:name "Test" :engine "h2" :details "{}"})] @@ -655,7 +610,6 @@ :group_id group-id :perm_type :perms/create-queries))) (t2/delete! :model/Database :id new-db-id))) - (testing "A new database gets `no` query permissions if a group has `no` for any database" (data-perms/set-database-permission! group-id db-id-2 :perms/create-queries :no) (let [new-db-id (t2/insert-returning-pk! :model/Database {:name "Test" :engine "h2" :details "{}"})] @@ -665,7 +619,6 @@ :group_id group-id :perm_type :perms/create-queries))) (t2/delete! :model/Database :id new-db-id)))) - (t2/delete! :model/DataPermissions :group_id group-id) (testing "Download permissions... " (testing "A new database gets `one-million-rows` download permissions if a group only has `one-million-rows` for other databases" @@ -677,7 +630,6 @@ :group_id group-id :perm_type :perms/download-results))) (t2/delete! :model/Database :id new-db-id))) - (testing "A new database gets `no` download permissions if a group has `no` for any database" (data-perms/set-database-permission! group-id db-id-2 :perms/download-results :no) (let [new-db-id (t2/insert-returning-pk! :model/Database {:name "Test" :engine "h2" :details "{}"})] @@ -708,20 +660,17 @@ ;; nil table ID is passed to check DB-level value (is (= :query-builder (perm-value nil))) (is (nil? (perm-value table-id-4))))) - (testing "New table inherits uniform permission value from schema" (data-perms/set-table-permission! group-id table-id-1 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id table-id-2 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id table-id-3 :perms/create-queries :no) (mt/with-temp [:model/Table {table-id-4 :id} {:db_id db-id :schema "PUBLIC"}] (is (= :query-builder (perm-value table-id-4)))) - (data-perms/set-table-permission! group-id table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! group-id table-id-2 :perms/create-queries :no) (data-perms/set-table-permission! group-id table-id-3 :perms/create-queries :query-builder) (mt/with-temp [:model/Table {table-id-4 :id} {:db_id db-id :schema "PUBLIC"}] (is (= :no (perm-value table-id-4))))) - (testing "New table uses default value when schema permissions are not uniform" (data-perms/set-table-permission! group-id table-id-1 :perms/create-queries :query-builder) (data-perms/set-table-permission! group-id table-id-2 :perms/create-queries :no) @@ -1041,15 +990,12 @@ (testing "cache enabled, current user" (binding [data-perms/*use-perms-cache?* true] (is (#'data-perms/use-cache? current-user-id)))) - (testing "cache enabled, different user" (binding [data-perms/*use-perms-cache?* true] (is (not (#'data-perms/use-cache? other-user-id))))) - (testing "cache disabled, current user" (binding [data-perms/*use-perms-cache?* false] (is (not (#'data-perms/use-cache? current-user-id))))) - (testing "cache disabled, different user" (binding [data-perms/*use-perms-cache?* false] (is (not (#'data-perms/use-cache? other-user-id))))))))) diff --git a/test/metabase/permissions/models/permissions_group_membership_test.clj b/test/metabase/permissions/models/permissions_group_membership_test.clj index a1ebc19f142b..e93595c5cdba 100644 --- a/test/metabase/permissions/models/permissions_group_membership_test.clj +++ b/test/metabase/permissions/models/permissions_group_membership_test.clj @@ -22,12 +22,10 @@ (perms/remove-user-from-group! user (perms-group/admin)) (is (= false (t2/select-one-fn :is_superuser :model/User :id (u/the-id user)))))) - (testing "it should not let you remove the last admin" (mt/with-single-admin-user! [{id :id}] (is (thrown? Exception (perms/remove-user-from-group! id (perms-group/admin)))))) - (testing "it should not let you remove the last non-archived admin" (mt/with-single-admin-user! [{id :id}] (mt/with-temp [:model/User _ {:is_active false @@ -49,7 +47,6 @@ (is (= "PermissionsGroupMembership" (:model audit-entry))) (is (= user-id (get-in audit-entry [:details :user_id]))) (is (= group-id (get-in audit-entry [:details :group_id]))))) - (testing "removing user from group is audited" (let [before-remove-count (t2/count :model/AuditLog)] (perms/remove-user-from-group! user-id group-id) diff --git a/test/metabase/permissions/models/permissions_group_test.clj b/test/metabase/permissions/models/permissions_group_test.clj index 34996b747386..37c33f3c3d7b 100644 --- a/test/metabase/permissions/models/permissions_group_test.clj +++ b/test/metabase/permissions/models/permissions_group_test.clj @@ -158,7 +158,6 @@ :perms/manage-table-metadata :no :perms/manage-database :no}}} (data-perms.graph/data-permissions-graph :group-id group-id :db-id db-id)))))) - (mt/with-temp [:model/Database {db-id :id} {}] (mt/with-no-data-perms-for-all-users! (mt/with-temp [:model/PermissionsGroup {group-id :id} {}] @@ -191,13 +190,11 @@ (into {} results) (update-vals results (fn [members] (set (map #(select-keys % [:id :is_group_manager]) members))))))] - (testing "hydrate members only return active users for each group" (is (= {group-id-1 #{{:id user-1-g1} {:id user-2-g1}} group-id-2 #{{:id user-1-g2}}} (group-id->members)))) - (testing "return is_group_manager for each group if premium features are enabled" (when config/ee-available? (mt/with-premium-features #{:advanced-permissions} diff --git a/test/metabase/permissions/models/permissions_test.clj b/test/metabase/permissions/models/permissions_test.clj index 6418c4fc7357..26ca1df1db0b 100644 --- a/test/metabase/permissions/models/permissions_test.clj +++ b/test/metabase/permissions/models/permissions_test.clj @@ -94,7 +94,6 @@ [{:collection_id 1337} :write] #{"/collection/1337/"} [{:collection_id nil} :read] #{"/collection/root/read/"} [{:collection_id nil} :write] #{"/collection/root/"}) - (testing "invalid input" (doseq [[reason inputs] {"map must have `:collection_id` key" [[{} :read]] @@ -124,7 +123,6 @@ (perms/revoke-collection-permissions! (perms-group/all-users) (u/the-id (t2/select-one :model/Collection :personal_owner_id (mt/user->id :lucky)))))) - (testing "(should apply to descendants as well)" (mt/with-temp [:model/Collection collection {:location (collection/children-location (collection/user->personal-collection @@ -153,7 +151,6 @@ Exception (f (perms-group/all-users) (u/the-id (t2/select-one :model/Collection :personal_owner_id (mt/user->id :lucky)))))) - (testing "(should apply to descendants as well)" (is (thrown? Exception @@ -287,6 +284,5 @@ (mt/with-current-user (mt/user->id :crowberto) (testing "admin can create dashboard in any collection" (is (true? (mi/can-create? :model/Dashboard {:collection_id (:id coll)})))) - (testing "admin can create dashboard in root collection" (is (true? (mi/can-create? :model/Dashboard {})))))))) diff --git a/test/metabase/permissions/user_test.clj b/test/metabase/permissions/user_test.clj index 2bfc645d5e10..92188abc9160 100644 --- a/test/metabase/permissions/user_test.clj +++ b/test/metabase/permissions/user_test.clj @@ -39,7 +39,6 @@ (is (contains? (permissions.user/user-permissions-set (mt/user->id :lucky)) (permissions.path/collection-readwrite-path (collection/user->personal-collection (mt/user->id :lucky))))) - (testing "...and for any descendant Collections of my Personal Collection?" (mt/with-temp [:model/Collection child-collection {:name "child" :location (collection/children-location diff --git a/test/metabase/permissions/util_test.clj b/test/metabase/permissions/util_test.clj index a003ce8e358c..10d84e0f66d8 100644 --- a/test/metabase/permissions/util_test.clj +++ b/test/metabase/permissions/util_test.clj @@ -163,7 +163,6 @@ "/download/limited/" "/download/db/1/schema/PUBLIC/table/1/query/" "/download/db/1/schema/PUBLIC/table/1/query/segmented/"]}] - (testing reason (doseq [path paths] (testing (str "\n" (pr-str path)) @@ -242,7 +241,6 @@ "Should set before to empty map") (is (= {} (:after latest-revision)) "Should set after to empty map"))))) - (testing "increment-implicit-perms-revision! should do nothing when no current user is set" (let [initial-count (t2/count :model/CollectionPermissionGraphRevision) remark "Test remark without user"] @@ -251,7 +249,6 @@ (let [final-count (t2/count :model/CollectionPermissionGraphRevision)] (is (= initial-count final-count) "Should not insert any revision record when no current user is set")))) - (testing "increment-implicit-perms-revision! should increment ID correctly" (let [initial-latest-id (collection-permission-graph-revision/latest-id) remark "Test ID increment"] diff --git a/test/metabase/permissions_rest/api_test.clj b/test/metabase/permissions_rest/api_test.clj index 885378a5903a..dd9da4750391 100644 --- a/test/metabase/permissions_rest/api_test.clj +++ b/test/metabase/permissions_rest/api_test.clj @@ -49,7 +49,6 @@ (get id->group (:id (perms-group/admin)))))))] (let [id->group (m/index-by :id (fetch-groups))] (check-default-groups-returned id->group)) - (testing "should return empty groups" (mt/with-temp [:model/PermissionsGroup group] (let [id->group (m/index-by :id (fetch-groups))] @@ -94,19 +93,15 @@ external-groups (fetch-groups :tenancy "external") regular-id (:id regular-group) tenant-id (:id tenant-group)] - (testing "default behavior (no tenancy param) returns all groups" (is (some #(= regular-id (:id %)) all-groups)) (is (some #(= tenant-id (:id %)) all-groups))) - (testing "tenancy=internal returns only non-tenant groups" (is (some #(= regular-id (:id %)) internal-groups)) (is (not (some #(= tenant-id (:id %)) internal-groups)))) - (testing "tenancy=external returns only tenant groups" (is (not (some #(= regular-id (:id %)) external-groups))) (is (some #(= tenant-id (:id %)) external-groups))) - (testing "magic groups are handled correctly" (let [all-internal-users-id (:id (perms-group/all-users)) find-group-by-type (fn [groups magic-type] @@ -116,7 +111,6 @@ (testing "all-external-users appears in external filter when available" (when-let [external-users-group (find-group-by-type all-groups "all-external-users")] (is (some #(= (:id external-users-group) (:id %)) external-groups)))))))))) - (testing "when tenants feature is disabled" (mt/with-temporary-setting-values [use-tenants false] (mt/with-temp [:model/PermissionsGroup regular-group {:name "Regular Group" :is_tenant_group false}] @@ -124,16 +118,12 @@ internal-groups (fetch-groups :tenancy "internal") external-groups (fetch-groups :tenancy "external") regular-id (:id regular-group)] - (testing "default behavior excludes tenant groups when tenants disabled" (is (some #(= regular-id (:id %)) all-groups))) - (testing "tenancy=internal still works when tenants disabled" (is (some #(= regular-id (:id %)) internal-groups))) - (testing "tenancy=external returns empty when tenants disabled" (is (empty? external-groups))))))) - (testing "invalid tenancy value returns 400" (:status (mt/user-http-request :crowberto :get 400 "permissions/group" :tenancy "invalid"))))) @@ -166,11 +156,9 @@ (testing "Should *not* include inactive users" (is (nil? (get id->member :trashbird))))) - (testing "returns 404 for nonexistent id" (is (= "Not found." (mt/user-http-request :crowberto :get 404 "permissions/group/10000")))) - (testing "requires superuers" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "permissions/group/%d" (:id (perms-group/all-users))))))))) @@ -181,30 +169,25 @@ (mt/with-model-cleanup [:model/PermissionsGroup] (mt/user-http-request :crowberto :post 200 "permissions/group" {:name "Test Group"}) (is (some? (t2/select :model/PermissionsGroup :name "Test Group"))))) - (testing "requires superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "permissions/group" {:name "Test Group"})))) - (testing "group name is required" (is (= {:errors {:name "value must be a non-blank string."}, :specific-errors {:name ["should be a string, received: nil" "non-blank string, received: nil"]}} (mt/user-http-request :crowberto :post 400 "permissions/group" {:name nil})))) - (testing "creates regular group by default" (mt/with-model-cleanup [:model/PermissionsGroup] (mt/user-http-request :crowberto :post 200 "permissions/group" {:name "Regular Group"}) (let [group (t2/select-one :model/PermissionsGroup :name "Regular Group")] (is (some? group)) (is (false? (:is_tenant_group group)))))) - (testing "creates regular group when is_tenant_group is explicitly false" (mt/with-model-cleanup [:model/PermissionsGroup] (mt/user-http-request :crowberto :post 200 "permissions/group" {:name "Explicit Regular Group" :is_tenant_group false}) (let [group (t2/select-one :model/PermissionsGroup :name "Explicit Regular Group")] (is (some? group)) (is (false? (:is_tenant_group group)))))) - (testing "creates regular group when is_tenant_group is nil" (mt/with-model-cleanup [:model/PermissionsGroup] (mt/user-http-request :crowberto :post 200 "permissions/group" {:name "Nil Tenant Group" :is_tenant_group nil}) @@ -225,7 +208,6 @@ (mt/with-temp [:model/PermissionsGroup {group-id :id} {:name "Test group"}] (mt/user-http-request :crowberto :delete 204 (format "permissions/group/%d" group-id)) (is (= 0 (t2/count :model/PermissionsGroup :name "Test group"))))) - (testing "requires superuser" (mt/with-temp [:model/PermissionsGroup {group-id :id} {:name "Test group"}] (is (= "You don't have permissions to do that." @@ -289,7 +271,6 @@ {db-id {:view-data "unrestricted" :create-queries "query-builder-and-native"}}}} graph))))) - (testing "make sure a non-admin cannot fetch the perms graph from the API" (mt/user-http-request :rasta :get 403 "permissions/graph")))) @@ -389,15 +370,12 @@ returned-g (do-perm-put "permissions/graph") returned-g-two (do-perm-put "permissions/graph?skip-graph=false") no-returned-g (do-perm-put "permissions/graph?skip-graph=true")] - (testing "returned-g" (is (perm-test-util/validate-graph-api-groups (:groups returned-g))) (is (mr/validate [:map [:revision pos-int?]] returned-g))) - (testing "return-g-two" (is (perm-test-util/validate-graph-api-groups (:groups returned-g-two))) (is (mr/validate [:map [:revision pos-int?]] returned-g-two))) - (testing "no returned g" (is (not (perm-test-util/validate-graph-api-groups (:groups no-returned-g)))) (is (mr/validate [:map {:closed true} @@ -413,7 +391,6 @@ (is (= (str "Looks like someone else edited the permissions and your data is out of date. " "Please fetch new data and try again.") (do-perm-put "permissions/graph?force=false" 409))) - (do-perm-put "permissions/graph?force=true" 200))))) (deftest can-revoke-permsissions-via-graph-test @@ -473,7 +450,6 @@ {"PUBLIC" {table-id :unrestricted}}) (assoc-in [:groups (u/the-id group) db-id :download :schemas] {"PUBLIC" {table-id :full}}))) - ;; Verify initial state (is (= :unrestricted (data-perms/table-permission-for-user (mt/user->id :rasta) @@ -493,7 +469,6 @@ {"PUBLIC" {table-id :blocked}}) (assoc-in [:groups (u/the-id group) db-id :download :schemas] {"PUBLIC" {table-id :full}}))) - ;; Verify that download-results was automatically set to no (is (= :blocked (data-perms/table-permission-for-user (mt/user->id :rasta) @@ -511,7 +486,6 @@ (testing "requires superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "permissions/membership")))) - (testing "Return a graph of membership" (let [result (mt/user-http-request :crowberto :get 200 "permissions/membership")] (is (malli= [:map-of ms/PositiveInt [:sequential [:map @@ -531,7 +505,6 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "permissions/membership" {:group_id (:id group) :user_id (:id user)})))) - (testing "Add membership successfully" (mt/user-http-request :crowberto :post 200 "permissions/membership" {:group_id (:id group) @@ -557,13 +530,11 @@ (testing "requires superuser permissions" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (format "permissions/membership/%d/clear" group-id))))) - (testing "Membership of a group can be cleared succesfully, while preserving the group itself" (is (= 1 (t2/count :model/PermissionsGroupMembership :group_id group-id))) (mt/user-http-request :crowberto :put 204 (format "permissions/membership/%d/clear" group-id)) (is (true? (t2/exists? :model/PermissionsGroup :id group-id))) (is (= 0 (t2/count :model/PermissionsGroupMembership :group_id group-id)))) - (testing "The admin group cannot be cleared using this endpoint" (mt/user-http-request :crowberto :put 400 (format "permissions/membership/%d/clear" (u/the-id (perms-group/admin)))))))) @@ -576,7 +547,6 @@ (testing "requires superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "permissions/membership/%d" id))))) - (testing "Delete membership successfully" (mt/user-http-request :crowberto :delete 204 (format "permissions/membership/%d" id)))))) diff --git a/test/metabase/permissions_rest/data_permissions/graph/bulk_update_test.clj b/test/metabase/permissions_rest/data_permissions/graph/bulk_update_test.clj index 1a91557ecf13..158fad0bec8c 100644 --- a/test/metabase/permissions_rest/data_permissions/graph/bulk_update_test.clj +++ b/test/metabase/permissions_rest/data_permissions/graph/bulk_update_test.clj @@ -372,7 +372,6 @@ (call-count))] (is (<= query-count 4) (format "Expected constant query count but got %d — possible N+1 regression" query-count))) - ;; Verify the permissions actually took effect by spot-checking a few values (let [result (data-perms.graph/data-permissions-graph :group-ids [g1 g2 g3])] (testing "g1/db1 db-level perms applied" diff --git a/test/metabase/pivot/core_test.cljc b/test/metabase/pivot/core_test.cljc index 39071a8a06c5..860ecbe6badf 100644 --- a/test/metabase/pivot/core_test.cljc +++ b/test/metabase/pivot/core_test.cljc @@ -195,17 +195,14 @@ num-breakouts 3] (is (= [0 1 2] (#'pivot/get-active-breakout-indexes pivot-group num-breakouts)))) - (let [pivot-group 1 ;; One inactive breakout (001 in binary) num-breakouts 3] (is (= [1 2] (#'pivot/get-active-breakout-indexes pivot-group num-breakouts)))) - (let [pivot-group 6 ;; Two inactive breakouts (110 in binary) num-breakouts 3] (is (= [0] (#'pivot/get-active-breakout-indexes pivot-group num-breakouts)))) - (let [pivot-group 7 ;; No active breakouts (111 in binary) num-breakouts 3] (is (= [] @@ -246,11 +243,9 @@ :isCollapsed false :value 2}] (lists-to-vecs-recursively (:row-tree result)))) - (is (=? [{:children [] :isCollapsed false :value "Y"} {:children [] :isCollapsed false :value "Z"}] (lists-to-vecs-recursively (:col-tree result)))) - (is (= [[["Y" 1 "A"] [10]] [["Z" 2 "B"] [20]]] (map @@ -288,7 +283,6 @@ :value 2}] (lists-to-vecs-recursively (:row-tree result))) "Row tree should have correct collapsed state for the root level")) - ;; Set up collapsed subtotals for level 2 (children of the root) (let [settings {:pivot_table.collapsed_rows {:value ["1"]}} result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes settings col-settings)] @@ -334,7 +328,6 @@ :value 2}] (lists-to-vecs-recursively (:row-tree result))) "Row tree should have correct collapsed state for the root level")) - ;; Set up collapsed subtotals for level 2 (children of the root) (let [settings {:pivot_table.collapsed_rows {:value ["1"]}} result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes settings col-settings)] @@ -369,7 +362,6 @@ ;; Test collapsing a specific node at the root level (let [settings {:pivot_table.collapsed_rows {:value ["[1]"]}} result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes settings col-settings)] - (is (=? [{:children [{:children [] :isCollapsed false :value "A"} {:children [] :isCollapsed false :value "B"}] @@ -381,11 +373,9 @@ :value 2}] (lists-to-vecs-recursively (:row-tree result))) "Row tree should have correct collapsed state for node with value 1 only")) - ;; Test collapsing a specific nested path (let [settings {:pivot_table.collapsed_rows {:value ["[1,\"A\"]"]}} result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes settings col-settings)] - (is (=? [{:children [{:children [] :isCollapsed true :value "A"} ;; Only [1,"A"] should be collapsed {:children [] :isCollapsed false :value "B"}] @@ -397,11 +387,9 @@ :value 2}] (lists-to-vecs-recursively (:row-tree result))) "Row tree should have correct collapsed state for nested path [1,\"A\"]")) - ;; Test collapsing multiple specific paths (let [settings {:pivot_table.collapsed_rows {:value ["[1,\"A\"]", "[2,\"B\"]"]}} result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes settings col-settings)] - (is (=? [{:children [{:children [] :isCollapsed true :value "A"} ;; [1,"A"] should be collapsed {:children [] :isCollapsed false :value "B"}] @@ -535,7 +523,6 @@ {:value 2 :children [{:value "A"} {:value "B"}]}] (lists-to-vecs-recursively (:row-tree result)))))) - (testing "descending sort order for first column" (let [result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes {} [{:pivot_table.column_sort_order "descending"} {} {} {} {}])] @@ -544,7 +531,6 @@ {:value 1 :children [{:value "A"} {:value "B"}]}] (lists-to-vecs-recursively (:row-tree result)))))) - (testing "descending sort order for first two columns" (let [result (pivot/build-pivot-trees rows cols row-indexes col-indexes val-indexes {} [{:pivot_table.column_sort_order "descending"} @@ -711,27 +697,22 @@ (is (= 5 (#'pivot/ensure-is-int 5))) (is (= 0 (#'pivot/ensure-is-int 0))) (is (= 7 (#'pivot/ensure-is-int 7)))) - (testing "Should handle long values" (is (= 5 (#'pivot/ensure-is-int 5N))) (is (= 0 (#'pivot/ensure-is-int 0N))) (is (= 7 (#'pivot/ensure-is-int 7N)))) - (testing "Should convert BigDecimal values that are losslessly convertible to int" (is (= 5 (#'pivot/ensure-is-int (BigDecimal. "5")))) (is (= 0 (#'pivot/ensure-is-int (BigDecimal. "0")))) (is (= 7 (#'pivot/ensure-is-int (BigDecimal. "7")))) (is (= 123 (#'pivot/ensure-is-int (BigDecimal. "123")))) - ;; Test with explicitly integer-valued BigDecimals (is (= 5 (#'pivot/ensure-is-int (BigDecimal. "5.0")))) (is (= 42 (#'pivot/ensure-is-int (BigDecimal. "42.00"))))) - (testing "Should handle edge cases" ;; Test with maximum int value as BigDecimal (is (= Integer/MAX_VALUE (#'pivot/ensure-is-int (BigDecimal. (str Integer/MAX_VALUE))))) - ;; Test with zero as BigDecimal (is (= 0 (#'pivot/ensure-is-int BigDecimal/ZERO)))) (testing "Should throw if it has decimal places" @@ -748,46 +729,37 @@ (let [pivot-group-bigdecimal (BigDecimal. "5") ; binary: 101 pivot-group-int 5 ; same value as int num-breakouts 3] - (doseq [bit-index (range num-breakouts)] (let [bit-mask (bit-shift-left 1 bit-index) bigdecimal-result (bit-and bit-mask (#'pivot/ensure-is-int pivot-group-bigdecimal)) int-result (bit-and bit-mask pivot-group-int)] (is (= int-result bigdecimal-result) (str "Bitwise AND with bit-mask " bit-mask " should be same for BigDecimal and int")))) - ;; Test the full active breakout logic (is (= (#'pivot/get-active-breakout-indexes pivot-group-int num-breakouts) (#'pivot/get-active-breakout-indexes pivot-group-bigdecimal num-breakouts)) "get-active-breakout-indexes should return same result for BigDecimal and int"))) - (testing "Works with decimal representations that are integers" (let [pivot-group-decimal (BigDecimal. "3.0") ; Should convert to 3 pivot-group-int 3 num-breakouts 3] - (is (= (#'pivot/get-active-breakout-indexes pivot-group-int num-breakouts) (#'pivot/get-active-breakout-indexes pivot-group-decimal num-breakouts)) "BigDecimal with .0 should work same as integer"))) - (testing "Handles edge case BigDecimal values" (is (= [0 1 2] (#'pivot/get-active-breakout-indexes BigDecimal/ZERO 3)) "BigDecimal/ZERO should work like integer 0") - (is (= [1 2] (#'pivot/get-active-breakout-indexes BigDecimal/ONE 3)) "BigDecimal/ONE should work like integer 1"))) - (testing "Memoization works correctly with BigDecimal values" (let [pivot-group (BigDecimal. "6") num-breakouts 3 first-call (#'pivot/get-active-breakout-indexes pivot-group num-breakouts) second-call (#'pivot/get-active-breakout-indexes pivot-group num-breakouts)] - (is (= first-call second-call)) (is (= [0] first-call) "pivot-group 6 with 3 breakouts should return [0]") - (let [equivalent-pivot-group (BigDecimal. "6.0") third-call (#'pivot/get-active-breakout-indexes equivalent-pivot-group num-breakouts)] (is (= first-call third-call) "Equivalent BigDecimal values should return same result")))))) diff --git a/test/metabase/premium_features/api_test.clj b/test/metabase/premium_features/api_test.clj index 8a6162ae8064..5e1a9df3d593 100644 --- a/test/metabase/premium_features/api_test.clj +++ b/test/metabase/premium_features/api_test.clj @@ -21,11 +21,9 @@ (with-redefs [premium-features/token-status (constantly fake-token-status)] (is (= fake-token-status (mt/user-http-request :crowberto :get 200 "premium-features/token/status"))))) - (testing "requires superusers" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "premium-features/token/status")))) - (testing "returns 404 if no token is set" (with-redefs [premium-features/token-status (constantly nil)] (is (= "Not found." @@ -41,11 +39,9 @@ (is (=? (dissoc fake-token-status :trial) (mt/user-http-request :crowberto :post 200 "premium-features/token/refresh"))) (is (true? @cleared?)))))) - (testing "requires superusers" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 "premium-features/token/refresh")))) - (testing "returns 404 if no token is set" (with-redefs [premium-features/token-status (constantly nil) premium-features/premium-embedding-token (constantly nil)] @@ -69,7 +65,6 @@ (is (= [(str "https://ai-service.example.com/v1/invalidate-token-cache/" token) {:throw-exceptions false}] @request*)))))))))) - (testing "POST /api/premium-features/token/refresh does not invalidate the AI service cache when it is not configured" (with-redefs [premium-features/token-status (constantly fake-token-status) premium-features/premium-embedding-token (constantly "proxy-token") diff --git a/test/metabase/premium_features/defenterprise_test.clj b/test/metabase/premium_features/defenterprise_test.clj index d56f908c8100..c695b1d76838 100644 --- a/test/metabase/premium_features/defenterprise_test.clj +++ b/test/metabase/premium_features/defenterprise_test.clj @@ -28,27 +28,22 @@ (testing "When EE code is not available, a call to a defenterprise function calls the OSS version" (is (= "Hi rasta, you're an OSS customer!" (greeting :rasta))))) - (when config/ee-available? (testing "When EE code is available" (testing "a call to a defenterprise function calls the EE version" (is (= "Hi rasta, you're running the Enterprise Edition of Metabase!" (greeting :rasta)))) - (testing "if a specific premium feature is required, it will check for it, and fall back to the OSS version by default" (mt/with-premium-features #{:special-greeting} (is (= "Hi rasta, you're an extra special EE customer!" (special-greeting :rasta)))) - (mt/with-premium-features #{} (is (= "Hi rasta, you're not extra special :(" (special-greeting :rasta))))) - (testing "when :fallback is a function, it is run when the required token is not present" (mt/with-premium-features #{:special-greeting} (is (= "Hi rasta, you're an extra special EE customer!" (special-greeting-or-custom :rasta)))) - (mt/with-premium-features #{} (is (= "Hi rasta, you're an EE customer but not extra special." (special-greeting-or-custom :rasta)))))))) @@ -94,33 +89,27 @@ (when-not config/ee-available? (testing "Argument schemas are validated for OSS implementations" (is (= "Hi rasta, the argument was valid" (greeting-with-schema :rasta))) - (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid input: \[\"should be a keyword, got: \\\"rasta\\\".*" (greeting-with-schema "rasta")))) - (testing "Return schemas are validated for OSS implementations" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid output: \[\"should be a keyword, got: \\\"Hi rasta.*" (greeting-with-invalid-oss-return-schema :rasta))))) - (when config/ee-available? (testing "Argument schemas are validated for EE implementations" (is (= "Hi rasta, the schema was valid, and you're running the Enterprise Edition of Metabase!" (greeting-with-schema :rasta))) - (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid input: \[\"should be a keyword, got: \\\"rasta\\\".*" (greeting-with-schema "rasta")))) (testing "Only EE schema is validated if EE implementation is called" (is (= "Hi rasta, the schema was valid, and you're running the Enterprise Edition of Metabase!" (greeting-with-invalid-oss-return-schema :rasta))) - (mt/with-premium-features #{:custom-feature} (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid output: \[\"should be a keyword, got: \\\"Hi rasta, the schema was valid.*" (greeting-with-invalid-ee-return-schema :rasta))))) - (testing "EE schema is not validated if OSS fallback is called" (is (= "Hi rasta, the return value was valid" (greeting-with-invalid-ee-return-schema :rasta)))))) diff --git a/test/metabase/premium_features/test_util.clj b/test/metabase/premium_features/test_util.clj index 8a8c22d25c74..8dd50ff2ffa9 100644 --- a/test/metabase/premium_features/test_util.clj +++ b/test/metabase/premium_features/test_util.clj @@ -13,9 +13,9 @@ [features thunk] (let [features (set (map name features))] (testing (format "\nWith premium token features = %s" (pr-str features)) - ;; non-thread-local usages need to do both [[binding]] AND [[with-redefs]], because if a thread-local usage - ;; happened already then the binding it establishes will shadow the value set by [[with-redefs]]. - ;; See [[with-premium-features-test]] below. + ;; non-thread-local usages need to do both [[binding]] AND [[with-redefs]], because if a thread-local usage + ;; happened already then the binding it establishes will shadow the value set by [[with-redefs]]. + ;; See [[with-premium-features-test]] below. (let [thunk (^:once fn* [] (binding [token-check/*token-features* (constantly features)] (thunk)))] diff --git a/test/metabase/premium_features/token_check_test.clj b/test/metabase/premium_features/token_check_test.clj index 82ccbe329b39..6272006f05f4 100644 --- a/test/metabase/premium_features/token_check_test.clj +++ b/test/metabase/premium_features/token_check_test.clj @@ -260,7 +260,6 @@ (is (= {:transform-basic-runs true :transform-advanced-runs false} (premium-features/locked-meters)))) - (testing "successful response WITHOUT :meters leaves the setting untouched" (reset! response {:valid true :status "ok"}) (token-check/-clear-cache! checker) @@ -270,7 +269,6 @@ :transform-advanced-runs false} (premium-features/locked-meters)) "Setting should retain previous value when response omits :meters")) - (testing "successful response with empty :meters {} writes empty map (legitimate unlock)" (reset! response {:valid true :status "ok" :meters {}}) (token-check/-clear-cache! checker) @@ -314,7 +312,6 @@ (testing "returns the number of active users" (is (= (t2/count :model/User :is_active true :type :personal) (premium-features/active-users-count)))) - (testing "Default to 0 if db is not setup yet" (binding [mdb.connection/*application-db* {:status (atom nil)}] (is (zero? (premium-features/active-users-count)))))) @@ -323,7 +320,6 @@ (testing "valid tokens" (is (mr/validate [:re @#'token-check/RemoteCheckedToken] (apply str (repeat 64 "a")))) (is (mr/validate [:re @#'token-check/RemoteCheckedToken] (apply str "mb_dev_" (repeat 57 "a"))))) - (testing "invalid tokens" (is (not (mr/validate [:re @#'token-check/RemoteCheckedToken] (apply str (repeat 64 "x"))))) (is (not (mr/validate [:re @#'token-check/RemoteCheckedToken] (apply str (repeat 65 "a"))))) @@ -334,17 +330,14 @@ (testing "no limit set - no error" (with-redefs [token-check/max-users-allowed (constantly nil)] (is (nil? (token-check/assert-valid-airgap-user-count!))))) - (testing "under limit - no error" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 5)] (is (nil? (token-check/assert-valid-airgap-user-count!))))) - (testing "at limit - no error" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 10)] (is (nil? (token-check/assert-valid-airgap-user-count!))))) - (testing "over limit - throws" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 11)] @@ -356,19 +349,16 @@ (testing "no limit set - no error" (with-redefs [token-check/max-users-allowed (constantly nil)] (is (nil? (token-check/assert-airgap-allows-user-creation!))))) - (testing "under limit - no error (room for one more)" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 9)] (is (nil? (token-check/assert-airgap-allows-user-creation!))))) - (testing "at limit - throws (no room for another)" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 10)] (is (thrown-with-msg? Exception #"Adding another user would exceed the maximum" (token-check/assert-airgap-allows-user-creation!))))) - (testing "over limit - throws" (with-redefs [token-check/max-users-allowed (constantly 10) token-check/active-user-count (constantly 11)] diff --git a/test/metabase/product_feedback/task/creator_sentiment_emails_test.clj b/test/metabase/product_feedback/task/creator_sentiment_emails_test.clj index 6b93d9ceaea4..a2aa185dec13 100644 --- a/test/metabase/product_feedback/task/creator_sentiment_emails_test.clj +++ b/test/metabase/product_feedback/task/creator_sentiment_emails_test.clj @@ -101,7 +101,6 @@ (let [creators (#'creator-sentiment-emails/fetch-creators false)] (is (= 1 (count creators))) (is (= creator-email (-> creators first :email))))) - (testing "Whitelabelling only fetches superusers (doesn't fetch anyone)." (let [creators (#'creator-sentiment-emails/fetch-creators true)] (is (= 0 (count creators))))))) diff --git a/test/metabase/public_sharing_rest/api_documents_test.clj b/test/metabase/public_sharing_rest/api_documents_test.clj index 6473bddd60a2..4396ef2b6177 100644 --- a/test/metabase/public_sharing_rest/api_documents_test.clj +++ b/test/metabase/public_sharing_rest/api_documents_test.clj @@ -74,19 +74,16 @@ (let [result (mt/client :get 200 (str "public/document/" (:public_uuid document)))] (testing "response includes cards field" (is (contains? result :cards))) - (testing "cards are returned as a map keyed by card ID" (is (map? (:cards result))) (is (= 2 (count (:cards result)))) (is (contains? (:cards result) card1-id)) (is (contains? (:cards result) card2-id))) - (testing "cards contain expected metadata" (is (= "Card 1" (get-in result [:cards card1-id :name]))) (is (= "Card 2" (get-in result [:cards card2-id :name]))) (is (= card1-id (get-in result [:cards card1-id :id]))) (is (= card2-id (get-in result [:cards card2-id :id])))) - (testing "cards do not include sensitive fields" (is (not (contains? (get-in result [:cards card1-id]) :collection_id))) (is (not (contains? (get-in result [:cards card1-id]) :creator_id))))))))) @@ -98,7 +95,6 @@ (mt/with-temp [:model/Document document (document-with-public-link {})] (is (= "An error occurred." (mt/client :get 400 (str "public/document/" (:public_uuid document))))))))) - (testing "Returns 404 if the Document doesn't exist" (mt/with-temporary-setting-values [enable-public-sharing true] (is (= "Not found." @@ -156,7 +152,6 @@ {})] (is (= 200 (:status response))) (is (= "text/csv" (get-in response [:headers "Content-Type"]))))) - (testing "Can export card results as JSON" (let [response (mt/client-full-response :post (format "public/document/%s/card/%d/json" (:public_uuid document) diff --git a/test/metabase/public_sharing_rest/api_test.clj b/test/metabase/public_sharing_rest/api_test.clj index 5d2252b4f4ef..821c201c566d 100644 --- a/test/metabase/public_sharing_rest/api_test.clj +++ b/test/metabase/public_sharing_rest/api_test.clj @@ -129,16 +129,13 @@ (testing "should return 400 if Card doesn't exist" (is (= "Not found." (client/client :get 404 (str "public/card/" (random-uuid)))))) - (with-temp-public-card [{uuid :public_uuid, card-id :id}] (testing "Happy path -- should be able to fetch the Card" (client/client :get 200 (str "public/card/" uuid))) - (testing "Check that we cannot fetch a public Card if public sharing is disabled" (mt/with-temporary-setting-values [enable-public-sharing false] (is (= "An error occurred." (client/client :get 400 (str "public/card/" uuid)))))) - (testing "Check that we cannot fetch a public Card that has been archived" (mt/with-temp-vals-in-db :model/Card card-id {:archived true} (is (= "Not found." @@ -303,15 +300,12 @@ (testing "Default :api response format" (is (= [[100]] (mt/rows (client/client :get 202 (str "public/card/" uuid "/query")))))) - (testing ":json download response format" (is (= [{:Count "100"}] (client/client :get 200 (str "public/card/" uuid "/query/json?format_rows=true"))))) - (testing ":csv download response format" (is (= "Count\n100\n" (client/client :get 200 (str "public/card/" uuid "/query/csv?format_rows=true"), :format :csv)))) - (testing ":xlsx download response format" (is (= [{:col "Count"} {:col 100.0}] (parse-xlsx-response @@ -572,19 +566,16 @@ (process-userland-query-test/with-query-execution! [qe query] (client/client :get 202 (str "public/card/" uuid "/query")) (is (= :public-question (:context (qe)))))))) - (let [query (mt/mbql-query venues)] (with-temp-public-card [{uuid :public_uuid} {:dataset_query query}] (testing ":json download response format" (process-userland-query-test/with-query-execution! [qe query] (client/client :get 200 (str "public/card/" uuid "/query/json")) (is (= :public-json-download (:context (qe)))))) - (testing ":xlsx download response format" (process-userland-query-test/with-query-execution! [qe query] (client/client :get 200 (str "public/card/" uuid "/query/xlsx")) (is (= :public-xlsx-download (:context (qe)))))) - (testing ":csv download response format" (process-userland-query-test/with-query-execution! [qe query] (client/client :get 200 (str "public/card/" uuid "/query/csv"), :format :csv) @@ -636,7 +627,6 @@ (with-temp-public-dashboard [{uuid :public_uuid}] (is (= "An error occurred." (client/client :get 400 (str "public/dashboard/" uuid))))))) - (testing "Should get a 400 if the Dashboard doesn't exist" (mt/with-temporary-setting-values [enable-public-sharing true] (is (= "Not found." @@ -770,16 +760,13 @@ (testing "if the Dashboard doesn't exist" (is (= "Not found." (client/client :get 404 (dashcard-url {:public_uuid (random-uuid)} card dashcard))))) - (testing "if the Card doesn't exist" (is (= "Not found." (client/client :get 404 (dashcard-url dash Integer/MAX_VALUE dashcard))))) - (testing "if the Card exists, but it's not part of this Dashboard" (mt/with-temp [:model/Card card] (is (= "Not found." (client/client :get 404 (dashcard-url dash card dashcard)))))) - (testing "if the Card has been archived." (t2/update! :model/Card (u/the-id card) {:archived true}) (is (= "Not found." @@ -791,7 +778,6 @@ (with-temp-public-dashboard-and-card [dash card dashcard] (is (= [[100]] (mt/rows (client/client :get 202 (dashcard-url dash card dashcard))))) - (testing "with parameters" (is (=? {:json_query {:parameters [{:id "_VENUE_ID_" :name "Venue ID" @@ -882,7 +868,6 @@ :value [10] :id "_VENUE_ID_"}])))) "This should pass because venue_id *is* one of the Dashboard's :parameters")) - (testing "should fail if" (testing "a parameter is passed that is not one of the Dashboard's parameters" (is (= "An error occurred." @@ -938,7 +923,6 @@ :value "50" :id "_NUM_"}])) mt/rows))))))) - (testing "with MBQL queries" (testing "`:id` parameters" (mt/with-temp [:model/Card card {:dataset_query (mt/mbql-query venues @@ -962,7 +946,6 @@ :value "50" :id "_VENUE_ID_"}])) mt/rows))))))) - (testing "temporal parameters" (mt/with-temporary-setting-values [enable-public-sharing true] (mt/with-temp [:model/Card card {:dataset_query (mt/mbql-query checkins @@ -1135,12 +1118,10 @@ (is (= {:values [["African"] ["American"] ["Asian"]] :has_more_values false} (client/client :get 200 (param-values-url :dashboard uuid (:static-category param-keys)))))) - (testing "parameter with source is card" (is (= {:values [["African"] ["American"] ["Artisan"] ["Asian"] ["BBQ"]] :has_more_values true} (client/client :get 200 (param-values-url :dashboard uuid (:card param-keys)))))) - (testing "parameter with source is chain filter" (is (= {:values [[2 "American"] [3 "Artisan"] [4 "Asian"] [5 "BBQ"] [6 "Bakery"]] :has_more_values false} @@ -1151,24 +1132,20 @@ :has_more_values false} (client/client :get 200 (param-values-url :dashboard uuid (:category-id param-keys)) (keyword (:id param-keys)) "7")))))) - (testing "GET /api/public/dashboard/:uuid/params/:param-key/search/:query" (testing "parameter with source is a static list" (is (= {:values [["African"]] :has_more_values false} (client/client :get 200 (param-values-url :dashboard uuid (:static-category param-keys) "af"))))) - (testing "parameter with source is card" (is (= {:values [["African"]] :has_more_values false} (client/client :get 200 (param-values-url :dashboard uuid (:card param-keys) "afr"))))) - (testing "parameter with source is a chain filter" (is (= {:values [["Fast Food"] ["Food Truck"] ["Seafood"]] :has_more_values false} (->> (client/client :get 200 (param-values-url :dashboard uuid (:category-name param-keys) "food")) (chain-filter-test/take-n-values 3))))))))) - (testing "with card" (api.card-test/with-card-param-values-fixtures [{:keys [card field-filter-card param-keys]}] (let [card-uuid (str (random-uuid)) @@ -1184,13 +1161,11 @@ (is (= {:values [["African"] ["American"] ["Asian"]] :has_more_values false} (client/client :get 200 (param-values-url :card card-uuid (:static-list param-keys)))))) - (testing "parameter with source is a card" (is (= {:values [["20th Century Cafe"] ["25°"] ["33 Taps"] ["800 Degrees Neapolitan Pizzeria"] ["BCD Tofu House"]] :has_more_values true} (client/client :get 200 (param-values-url :card card-uuid (:card param-keys)))))) - (testing "parameter with source is a field filter" (testing "parameter with source is a card" (let [resp (client/client @@ -1200,18 +1175,15 @@ (is (false? (:has_more_values resp))) (is (set/subset? #{["20th Century Cafe"] ["33 Taps"]} (-> resp :values set))))))) - (testing "GET /api/public/card/:uuid/params/:param-key/search/:query" (testing "parameter with source is a static list" (is (= {:values [["African"]] :has_more_values false} (client/client :get 200 (param-values-url :card card-uuid (:static-list param-keys) "af"))))) - (testing "parameter with source is a card" (is (= {:values [["Fred 62"] ["Red Medicine"]] :has_more_values false} (client/client :get 200 (param-values-url :card card-uuid (:card param-keys) "red"))))) - (testing "parameter with source is a field-filter" (is (partial= {:values [["Barney's Beanery"] @@ -1280,7 +1252,6 @@ :has_more_values false} (->> (mt/user-http-request :rasta :get 200 (param-values-url :dashboard uuid (:category-name param-keys) "food")) (chain-filter-test/take-n-values 3)))))))) - (testing "with card" (api.card-test/with-card-param-values-fixtures [{:keys [card param-keys]}] (let [uuid (str (random-uuid))] @@ -1290,17 +1261,14 @@ (is (= {:values [["African"] ["American"] ["Asian"]] :has_more_values false} (client/client :get 200 (param-values-url :card uuid (:static-list param-keys))))) - (is (= {:values [["20th Century Cafe"] ["25°"] ["33 Taps"] ["800 Degrees Neapolitan Pizzeria"] ["BCD Tofu House"]] :has_more_values true} (client/client :get 200 (param-values-url :card uuid (:card param-keys)))))) - (testing "GET /api/public/card/:uuid/params/:param-key/search/:query" (is (= {:values [["African"]] :has_more_values false} (client/client :get 200 (param-values-url :card uuid (:static-list param-keys) "afr")))) - (is (= {:values [["Fred 62"] ["Red Medicine"]] :has_more_values false} (client/client :get 200 (param-values-url :card uuid (:card param-keys) "red")))))))))))))) @@ -1442,7 +1410,6 @@ (is (= "completed" (:status result))) (is (= 6 (count (get-in result [:data :cols])))) (is (= 1144 (count rows))) - (is (= ["AK" "Affiliate" "Doohickey" 0 18 81] (first rows))) (is (= ["CO" "Affiliate" "Gadget" 0 62 211] (nth rows 100))) (is (= [nil nil nil 7 18760 69540] (last rows)))))))))) @@ -1481,7 +1448,7 @@ (let [result (results)] (is (=? {:status "completed"} result)) - ;; [[metabase.public-sharing-rest.api/transform-results]] should remove `row_count` + ;; [[metabase.public-sharing-rest.api/transform-results]] should remove `row_count` (testing "row_count isn't included in public endpoints" (is (nil? (:row_count result)))) (is (= 6 (count (get-in result [:data :cols])))) @@ -1522,16 +1489,13 @@ (testing "if the Dashboard doesn't exist" (is (= "Not found." (client/client :get 404 (dashcard-url {:public_uuid (random-uuid)} card dashcard))))) - (testing "if the Card doesn't exist" (is (= "Not found." (client/client :get 404 (dashcard-url dash Integer/MAX_VALUE dashcard))))) - (testing "if the Card exists, but it's not part of this Dashboard" (mt/with-temp [:model/Card card] (is (= "Not found." (client/client :get 404 (dashcard-url dash card dashcard)))))) - (testing "if the Card has been archived." (t2/update! :model/Card (u/the-id card) {:archived true}) (is (= "Not found." @@ -1729,7 +1693,6 @@ :rows first first)))) - (testing "XLSX export" (is (= "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" (-> (client/client-full-response :post 200 (format "public/dashboard/%s/dashcard/%d/card/%d/xlsx" diff --git a/test/metabase/pulse/api/alert_test.clj b/test/metabase/pulse/api/alert_test.clj index 9554636c6651..e3fb51d9974b 100644 --- a/test/metabase/pulse/api/alert_test.clj +++ b/test/metabase/pulse/api/alert_test.clj @@ -105,7 +105,6 @@ :channel_type "http" :channel_id chn-id :enabled true})] - (is (= (sanitize-alert (mt/user-http-request :crowberto :get 200 (alert-url (:id notification)))) (sanitize-alert @@ -120,7 +119,6 @@ (testing "by default only active alerts are returned" (is (= #{(:id active-noti)} (set (map :id (mt/user-http-request :crowberto :get 200 "alert")))))) - (testing "can fetch archived alerts" (is (= #{(:id archived-noti)} (set (map :id (mt/user-http-request :crowberto :get 200 "alert" :archived true))))))))) @@ -135,20 +133,16 @@ :handlers [{:channel_type :channel/email :recipients [{:type :notification-recipient/user :user_id (mt/user->id :rasta)}]}]}] - (testing "admin can see all alerts" (is (= #{(:id crowberto-alert) (:id rasta-alert) (:id rasta-recipient-alert)} (set (map :id (mt/user-http-request :crowberto :get 200 "alert")))))) - (testing "can fetch alerts by user_id - should include created and received alerts" (is (= #{(:id crowberto-alert) (:id rasta-recipient-alert)} (set (map :id (mt/user-http-request :crowberto :get 200 "alert" :user_id (mt/user->id :crowberto)))))) - (is (= #{(:id rasta-alert) (:id rasta-recipient-alert)} (set (map :id (mt/user-http-request :crowberto :get 200 "alert" :user_id (mt/user->id :rasta))))))) - (testing "regular users can only see alerts they created or receive" (is (= #{(:id rasta-alert) (:id rasta-recipient-alert)} (set (map :id (mt/user-http-request :rasta :get 200 "alert")))))))))) @@ -159,7 +153,6 @@ [notification {}] (is (= (:id notification) (:id (mt/user-http-request :crowberto :get 200 (alert-url notification))))))) - (testing "fetching a non-existing alert returns an error" (mt/user-http-request :rasta :get 404 (str "alert/" Integer/MAX_VALUE)))) @@ -177,7 +170,6 @@ (thunk (models.notification/hydrate-notification (t2/select-one :model/Notification noti-id))))) email-recipients (fn [notification] (->> notification :handlers (m/find-first #(= :channel/email (:channel_type %))) :recipients))] - (testing "creator can unsubscribe themselves" (unsubscribe :crowberto 204 @@ -186,7 +178,6 @@ [{:type :notification-recipient/user :user_id (mt/user->id :lucky)}] (email-recipients noti)))))) - (testing "recipient can unsubscribe themselves" (unsubscribe :lucky 204 @@ -195,7 +186,6 @@ [{:type :notification-recipient/user :user_id (mt/user->id :crowberto)}] (email-recipients noti)))))) - (testing "non-recipient cannot unsubscribe" (unsubscribe :rasta 403 diff --git a/test/metabase/pulse/api/pulse_test.clj b/test/metabase/pulse/api/pulse_test.clj index e9505567c83e..ecaabe978b90 100644 --- a/test/metabase/pulse/api/pulse_test.clj +++ b/test/metabase/pulse/api/pulse_test.clj @@ -376,7 +376,6 @@ (create-pulse! 200 pulse-name card collection) (is (= {:collection_id (u/the-id collection), :collection_position 1} (mt/derecordize (t2/select-one [:model/Pulse :collection_id :collection_position] :name pulse-name))))))) - (testing "...but not if we don't have permissions for the Collection" (mt/with-non-admin-groups-no-root-collection-perms (let [pulse-name (mt/random-name)] @@ -412,11 +411,9 @@ (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" (mt/user-http-request :crowberto :post 403 "pulse" (assoc-in pulse [:channels 0 :recipients] failed-recipients))))) - (testing "success if recipients matches allowed domains" (mt/user-http-request :crowberto :post 200 "pulse" (assoc-in pulse [:channels 0 :recipients] success-recipients)))) - (testing "on update" (mt/with-temp [:model/Pulse {pulse-id :id} {:name "Test Pulse" :dashboard_id dashboard-id}] @@ -424,17 +421,14 @@ (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" (mt/user-http-request :crowberto :put 403 (format "pulse/%d" pulse-id) (assoc-in pulse [:channels 0 :recipients] failed-recipients))))) - (testing "success if recipients matches allowed domains" (mt/user-http-request :crowberto :put 200 (format "pulse/%d" pulse-id) (assoc-in pulse [:channels 0 :recipients] success-recipients))))) - (testing "on test send" (testing "fail if recipients does not match allowed domains" (is (= "The following email addresses are not allowed: ngoc@metabase.com, ngoc@metaba.be" (mt/user-http-request :crowberto :post 403 "pulse/test" (assoc-in pulse [:channels 0 :recipients] failed-recipients))))) - (testing "success if recipients matches allowed domains" (mt/user-http-request :crowberto :post 200 "pulse/test" (assoc-in pulse [:channels 0 :recipients] success-recipients))))))))))) @@ -573,7 +567,6 @@ ;; Check to make sure the ID has changed in the DB (is (= (t2/select-one-fn :collection_id :model/Pulse :id (u/the-id pulse)) (u/the-id new-collection))))) - (testing "...but if we don't have the Permissions for the old collection, we should get an Exception" (pulse-test/with-pulse-in-collection! [_db _collection pulse] (mt/with-temp [:model/Collection new-collection] @@ -582,7 +575,6 @@ ;; now make an API call to move collections. Should fail (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "pulse/" (u/the-id pulse)) {:collection_id (u/the-id new-collection)})))))) - (testing "...and if we don't have the Permissions for the new collection, we should get an Exception" (pulse-test/with-pulse-in-collection! [_db collection pulse] (mt/with-temp [:model/Collection new-collection] @@ -600,7 +592,6 @@ {:collection_position 1}) (is (= 1 (t2/select-one-fn :collection_position :model/Pulse :id (u/the-id pulse))))) - (testing "...and unset (unpin) it as well?" (pulse-test/with-pulse-in-collection! [_ collection pulse] (t2/update! :model/Pulse (u/the-id pulse) {:collection_position 1}) @@ -609,14 +600,12 @@ {:collection_position nil}) (is (= nil (t2/select-one-fn :collection_position :model/Pulse :id (u/the-id pulse)))))) - (testing "...we shouldn't be able to if we don't have permissions for the Collection" (pulse-test/with-pulse-in-collection! [_db _collection pulse] (mt/user-http-request :rasta :put 403 (str "pulse/" (u/the-id pulse)) {:collection_position 1}) (is (= nil (t2/select-one-fn :collection_position :model/Pulse :id (u/the-id pulse)))) - (testing "shouldn't be able to unset (unpin) a Pulse" (t2/update! :model/Pulse (u/the-id pulse) {:collection_position 1}) (mt/user-http-request :rasta :put 403 (str "pulse/" (u/the-id pulse)) @@ -642,7 +631,6 @@ {:archived false}) (is (= false (t2/select-one-fn :archived :model/Pulse :id (u/the-id pulse)))))) - (testing "Does unarchiving a Pulse affect its Cards & Recipients? It shouldn't. This should behave as a PATCH-style endpoint!" (mt/with-non-admin-groups-no-root-collection-perms (mt/with-temp [:model/Collection collection {} @@ -825,7 +813,6 @@ "b" 3 "c" 4 "d" 5}} - {:message "Add a new pulse without a position, should leave existing positions unchanged" :action [:insert-pulse 1] :expected {"x" nil @@ -904,7 +891,6 @@ (assoc (pulse-details pulse-2) :can_write true, :collection_id true) (assoc (pulse-details pulse-3) :can_write true, :collection_id true)] (map #(update % :collection_id boolean) results))))) - (testing "non-admins only see pulses they created by default" (let [results (-> (mt/user-http-request :rasta :get 200 "pulse") (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] @@ -912,7 +898,6 @@ (is (partial= [(assoc (pulse-details pulse-1) :can_write true, :collection_id true)] (map #(update % :collection_id boolean) results))))) - (testing "when `creator_or_recipient=true`, all users only see pulses they created or are a recipient of" (let [expected-pulse-shape (fn [pulse] (-> pulse pulse-details @@ -924,7 +909,6 @@ (is (partial= [(expected-pulse-shape pulse-2) (expected-pulse-shape pulse-3)] (map #(update % :collection_id boolean) results)))) - (let [results (-> (mt/user-http-request :rasta :get 200 "pulse?creator_or_recipient=true") (filter-pulse-results :id #{pulse-1-id pulse-2-id pulse-3-id}))] (is (= 2 (count results))) @@ -932,7 +916,6 @@ [(expected-pulse-shape pulse-1) (assoc (expected-pulse-shape pulse-3) :can_write false)] (map #(update % :collection_id boolean) results))))))) - (with-pulses-in-nonreadable-collection! [pulse-3] (testing "when `creator_or_recipient=true`, cards and recipients are not included in results if the user does not have collection perms" @@ -941,7 +924,6 @@ first)] (is (nil? (:cards result))) (is (nil? (get-in result [:channels 0 :recipients]))))))) - (testing "should not return alerts" (mt/with-temp [:model/Pulse pulse-1 {:name "ABCDEF"} :model/Pulse pulse-2 {:name "GHIJKL"} @@ -953,7 +935,6 @@ (for [pulse (-> (mt/user-http-request :rasta :get 200 "pulse") (filter-pulse-results :name #{"ABCDEF" "GHIJKL" "AAAAAA"}))] (update pulse :collection_id boolean))))))) - (testing "by default, archived Pulses should be excluded" (mt/with-temp [:model/Pulse not-archived-pulse {:name "Not Archived"} :model/Pulse archived-pulse {:name "Archived" :archived true}] @@ -961,7 +942,6 @@ (is (= #{"Not Archived"} (set (map :name (-> (mt/user-http-request :rasta :get 200 "pulse") (filter-pulse-results :name #{"Not Archived" "Archived"}))))))))) - (testing "can we fetch archived Pulses?" (mt/with-temp [:model/Pulse not-archived-pulse {:name "Not Archived"} :model/Pulse archived-pulse {:name "Archived" :archived true}] @@ -969,7 +949,6 @@ (is (= #{"Archived"} (set (map :name (-> (mt/user-http-request :rasta :get 200 "pulse?archived=true") (filter-pulse-results :name #{"Not Archived" "Archived"}))))))))) - (testing "excludes dashboard subscriptions associated with archived dashboards" (mt/with-temp [:model/Dashboard {dashboard-id :id} {:archived true} :model/Pulse {pulse-id :id} {:dashboard_id dashboard-id}] @@ -985,17 +964,14 @@ :collection_id true) (-> (mt/user-http-request :rasta :get 200 (str "pulse/" (u/the-id pulse))) (update :collection_id boolean)))))) - (testing "cannot normally fetch a pulse without collection permissions" (mt/with-temp [:model/Pulse pulse {:creator_id (mt/user->id :crowberto)}] (with-pulses-in-nonreadable-collection! [pulse] (mt/user-http-request :rasta :get 403 (str "pulse/" (u/the-id pulse)))))) - (testing "can fetch a pulse without collection permissions if you are the creator or a recipient" (mt/with-temp [:model/Pulse pulse {:creator_id (mt/user->id :rasta)}] (with-pulses-in-nonreadable-collection! [pulse] (mt/user-http-request :rasta :get 200 (str "pulse/" (u/the-id pulse))))) - (mt/with-temp [:model/Pulse pulse {:creator_id (mt/user->id :crowberto)} :model/PulseChannel pc {:pulse_id (u/the-id pulse)} :model/PulseChannelRecipient _ {:pulse_channel_id (u/the-id pc) @@ -1275,9 +1251,7 @@ (testing "Check that Slack channels come back when configured" (mt/with-temporary-setting-values [channel.settings/slack-channels-and-usernames-last-updated (t/zoned-date-time) - channel.settings/slack-app-token "test-token" - channel.settings/slack-cached-channels-and-usernames {:channels [{:type "channel" :name "foo" @@ -1297,13 +1271,10 @@ {:displayName "@user1" :id "U1DYU9W3WZ2"}], :required true}] (-> (mt/user-http-request :rasta :get 200 "pulse/form_input") (get-in [:channels :slack :fields])))))) - (testing "Duplicate Slack channel display names are deduplicated" (mt/with-temporary-setting-values [channel.settings/slack-channels-and-usernames-last-updated (t/zoned-date-time) - channel.settings/slack-app-token "test-token" - channel.settings/slack-cached-channels-and-usernames {:channels [{:type "channel" :name "channel" @@ -1329,13 +1300,10 @@ (get-in [:channels :slack :fields]) first :options)))))) - (testing "Duplicate Slack channel IDs are deduplicated, keeping the first entry" (mt/with-temporary-setting-values [channel.settings/slack-channels-and-usernames-last-updated (t/zoned-date-time) - channel.settings/slack-app-token "test-token" - channel.settings/slack-cached-channels-and-usernames {:channels [{:type "channel" :name "old-name" @@ -1355,7 +1323,6 @@ (get-in [:channels :slack :fields]) first :options))))) - (testing "When slack is not configured, `form_input` returns no channels" (mt/with-temporary-setting-values [channel.settings/slack-app-token nil] (is (empty? @@ -1376,7 +1343,6 @@ (mt/with-temp [:model/PulseChannelRecipient _ {:pulse_channel_id channel-id :user_id (mt/user->id :rasta)}] (is (= nil (mt/user-http-request :rasta :delete 204 (str "pulse/" pulse-id "/subscription")))))) - (testing "Users can't delete someone else's pulse subscription" (mt/with-temp [:model/PulseChannelRecipient _ {:pulse_channel_id channel-id :user_id (mt/user->id :rasta)}] (is (= "Not found." diff --git a/test/metabase/pulse/api/unsubscribe_test.clj b/test/metabase/pulse/api/unsubscribe_test.clj index db204137889a..e7cdace085e5 100644 --- a/test/metabase/pulse/api/unsubscribe_test.clj +++ b/test/metabase/pulse/api/unsubscribe_test.clj @@ -11,7 +11,6 @@ expected-hash "37bc76b4a24279eb90a71c129a629fb8626ad0089f119d6d095bc5135377f2e2884ad80b037495f1962a283cf57cdbad031fd1f06a21d86a40bba7fe674802dd"] (testing "We generate a cryptographic hash to validate unsubscribe URLs" (is (= expected-hash (messages/generate-pulse-unsubscribe-hash pulse-id email)))) - (testing "The hash value depends on the pulse-id, email, and site-uuid" (let [alternate-site-uuid "aa147515-ade9-4298-ac5f-c7e42b69286d" alternate-hashes [(messages/generate-pulse-unsubscribe-hash 87654321 email) @@ -28,7 +27,6 @@ (mt/client :post 400 "pulse/unsubscribe" {:pulse-id 1 :email email :hash "fake-hash"})))) - (testing "Valid hash but not email" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id}] @@ -36,7 +34,6 @@ (mt/client :post 400 "pulse/unsubscribe" {:pulse-id pulse-id :email email :hash (messages/generate-pulse-unsubscribe-hash pulse-id email)}))))) - (testing "Valid hash and email" (mt/with-temp [:model/Pulse {pulse-id :id} {:name "title"} :model/PulseChannel _ {:pulse_id pulse-id @@ -73,7 +70,6 @@ (mt/client :post 400 "pulse/unsubscribe/undo" {:pulse-id 1 :email email :hash "fake-hash"})))) - (testing "Valid hash and email doesn't exist" (mt/with-temp [:model/Pulse {pulse-id :id} {:name "title"} :model/PulseChannel _ {:pulse_id pulse-id}] @@ -81,7 +77,6 @@ (mt/client :post 200 "pulse/unsubscribe/undo" {:pulse-id pulse-id :email email :hash (messages/generate-pulse-unsubscribe-hash pulse-id email)}))))) - (testing "Valid hash and email already exists" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id diff --git a/test/metabase/pulse/dashboard_subscription_test.clj b/test/metabase/pulse/dashboard_subscription_test.clj index 216222703509..dcd838bf0e24 100644 --- a/test/metabase/pulse/dashboard_subscription_test.clj +++ b/test/metabase/pulse/dashboard_subscription_test.clj @@ -624,23 +624,18 @@ #"https://testmb\.com/collection/\d+" #"Linked collection name" #"Linked collection desc" - #"https://testmb\.com/browse/\d+" #"Linked database name" #"Linked database desc" - #"https://testmb\.com/question\?db=\d+table=\d+" #"Linked table dname" #"Linked table desc" - #"https://testmb\.com/question/\d+" #"Linked card name" #"Linked card desc" - #"https://testmb\.com/question/\d+" #"Linked model name" #"Linked model desc" - #"https://testmb\.com/dashboard/\d+" #"Linked Dashboard name" #"Linked Dashboard desc") @@ -835,7 +830,6 @@ (is (=? [{:text "Markdown"} {:text "### [https://metabase.com](https://metabase.com)"}] (execute-dashboard (:id dashboard) (mt/user->id :rasta) nil))))) - (testing "Link cards are returned and info should be newly fetched" (mt/with-temp [:model/Dashboard dashboard {:name "Test Dashboard"}] (with-link-card-fixture-for-dashboard dashboard [{:keys [collection-owner-id @@ -886,7 +880,6 @@ (is (=? [{:text "Markdown"} {:text "### [https://metabase.com](https://metabase.com)"}] (execute-dashboard (:id dashboard) (mt/user->id :rasta) nil))))) - (testing "Link cards are returned and info should be newly fetched" (mt/with-temp [:model/Dashboard dashboard {:name "Test Dashboard"}] (with-link-card-fixture-for-dashboard dashboard [{:keys [collection-owner-id @@ -913,7 +906,6 @@ {:text (format "### [New Card name](%s/question/%d)\nLinked model desc" site-url model-id)} {:text "### [https://metabase.com](https://metabase.com)"}] (execute-dashboard (:id dashboard) collection-owner-id nil)))) - (testing "it should filter out models that current users does not have permission to read" (is (=? [{:text (format "### [New Database name](%s/browse/%d)\nLinked database desc" site-url database-id)} {:text (format "### [Linked table dname](%s/question?db=%d&table=%d)\nLinked table desc" site-url database-id table-id)} @@ -1213,7 +1205,6 @@ pulse.test-util/png-attachment pulse.test-util/csv-attachment]}) (mt/summarize-multipart-single-email email #"Aviary KPIs"))))}} - "xlsx" {:pulse-card {:include_xls true} :assert @@ -1223,7 +1214,6 @@ pulse.test-util/png-attachment pulse.test-util/xls-attachment]}) (mt/summarize-multipart-single-email email #"Aviary KPIs"))))}} - "no result should not include csv" {:card {:dataset_query (mt/mbql-query venues {:filter [:= $id -1]})} :pulse-card {:include_csv true} @@ -1365,7 +1355,6 @@ (mt/summarize-multipart-single-email (first (:channel/email pulse-results)) #"Aviary KPIs"))) - (is (=? {:channel "#general", :blocks (default-slack-blocks dashboard-id [card-id])} (pulse.test-util/thunk->boolean (first (:channel/slack pulse-results))))))))))))) diff --git a/test/metabase/pulse/models/pulse_channel_test.clj b/test/metabase/pulse/models/pulse_channel_test.clj index bb49107263ec..c01bcaadfcab 100644 --- a/test/metabase/pulse/models/pulse_channel_test.clj +++ b/test/metabase/pulse/models/pulse_channel_test.clj @@ -194,7 +194,6 @@ :recipients [{:email "foo@bar.com"} {:id (mt/user->id :rasta)} {:id (mt/user->id :crowberto)}]})))) - (testing "slack" (is (= (merge default-pulse-channel {:channel_type :slack @@ -222,7 +221,6 @@ :schedule_type :daily :schedule_hour 18 :recipients [{:email "foo@bar.com"}]}))))) - (testing "monthly schedules require a schedule_frame and can optionally omit they schedule_day" (mt/with-temp [:model/PulseChannel {channel-id :id} {:pulse_id pulse-id}] (is (= (merge default-pulse-channel @@ -240,7 +238,6 @@ :schedule_day nil :schedule_frame :mid :recipients [{:email "foo@bar.com"} {:id (mt/user->id :rasta)}]}))))) - (testing "weekly schedule should have a day in it, show that we can get full users" (mt/with-temp [:model/PulseChannel {channel-id :id} {:pulse_id pulse-id}] (is (= (merge default-pulse-channel @@ -257,7 +254,6 @@ :schedule_hour 8 :schedule_day "mon" :recipients [{:email "foo@bar.com"} {:id (mt/user->id :rasta)}]}))))) - (testing "hourly schedules don't require day/hour settings (should be nil), fully change recipients" (mt/with-temp [:model/PulseChannel {channel-id :id} {:pulse_id pulse-id, :details {:emails ["foo@bar.com"]}}] (pulse-channel/update-recipients! channel-id [(mt/user->id :rasta)]) @@ -275,7 +271,6 @@ :schedule_hour 12 :schedule_day "tue" :recipients [{:id (mt/user->id :crowberto)}]}))))) - (testing "custom details for channels that need it" (mt/with-temp [:model/PulseChannel {channel-id :id} {:pulse_id pulse-id}] (is (= (merge default-pulse-channel @@ -402,21 +397,17 @@ (testing "Creating a PulseChannel will creates a trigger" (is (= #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id])} (send-pulse-triggers pulse-id)))) - (testing "updating the schedule of a trigger will remove it from the existing trigger and create a new one" (t2/update! :model/PulseChannel pc-id daily-at-7pm) (is (=? #{(pulse->trigger-info pulse-id daily-at-7pm [pc-id])} (send-pulse-triggers pulse-id)))) - (testing "disable PC will delete its trigger" (t2/update! :model/PulseChannel pc-id {:enabled false}) (is (empty? (send-pulse-triggers pulse-id)))) - (testing "reenable PC will add its trigger" (t2/update! :model/PulseChannel pc-id {:enabled true}) (is (=? #{(pulse->trigger-info pulse-id daily-at-7pm [pc-id])} (send-pulse-triggers pulse-id)))) - (testing "remove the trigger if PC is deleted" (t2/delete! :model/PulseChannel pc-id) (is (empty? (send-pulse-triggers pulse-id))))))) @@ -430,32 +421,27 @@ (testing "pc 1 will have its own channel to start with" (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1])} (send-pulse-triggers pulse-id)))) - (testing "add a new pc with the same time will update the existing trigger" (mt/with-temp [:model/PulseChannel {pc-id-2 :id} (merge {:pulse_id pulse-id :channel_type :slack} daily-at-6pm)] (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1 pc-id-2])} (send-pulse-triggers pulse-id))) - (t2/delete! :model/PulseChannel pc-id-2) (testing "deleting channel-2 should remove the id, but keep the existing trigger" (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1])} (send-pulse-triggers pulse-id)))))) - (testing "add a new pc then change its schedule" (mt/with-temp [:model/PulseChannel {pc-id-2 :id} (merge {:pulse_id pulse-id :channel_type :slack} daily-at-6pm)] (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1 pc-id-2])} (send-pulse-triggers pulse-id))) - (testing "change schedule of a trigger will remove it from the existing trigger and create a new one" (t2/update! :model/PulseChannel pc-id-2 daily-at-7pm) (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1]) (pulse->trigger-info pulse-id daily-at-7pm [pc-id-2])} (send-pulse-triggers pulse-id)))) - (testing "change it back to the original schedule will remove the trigger and update channel-ids of the existing one" (t2/update! :model/PulseChannel pc-id-2 daily-at-6pm) (is (=? #{(pulse->trigger-info pulse-id daily-at-6pm [pc-id-1 pc-id-2])} diff --git a/test/metabase/pulse/models/pulse_test.clj b/test/metabase/pulse/models/pulse_test.clj index b1709197055d..a447b6c3fab2 100644 --- a/test/metabase/pulse/models/pulse_test.clj +++ b/test/metabase/pulse/models/pulse_test.clj @@ -391,18 +391,15 @@ pulse-channel-test/daily-at-6pm)] (is (= #{(pulse-channel-test/pulse->trigger-info pulse-id pulse-channel-test/daily-at-6pm [pc-id])} (pulse-channel-test/send-pulse-triggers pulse-id))) - (testing "archived pulse will disable pulse channels and remove triggers" (t2/update! :model/Pulse pulse-id {:archived true}) (is (false? (t2/select-one-fn :enabled :model/PulseChannel pc-id))) (is (empty? (pulse-channel-test/send-pulse-triggers pulse-id)))) - (testing "re-enabled pulse will re-enable pulse channels and add triggers" (t2/update! :model/Pulse pulse-id {:archived false}) (is (true? (t2/select-one-fn :enabled :model/PulseChannel pc-id))) (is (= #{(pulse-channel-test/pulse->trigger-info pulse-id pulse-channel-test/daily-at-6pm [pc-id])} (pulse-channel-test/send-pulse-triggers pulse-id)))) - (testing "delete pulse will remove pulse channels and triggers" (t2/delete! :model/Pulse pulse-id) (is (false? (t2/exists? :model/PulseChannel pc-id))) @@ -443,7 +440,6 @@ (t2/insert! :model/Pulse (assoc (mt/with-temp-defaults :model/Pulse) :collection_id collection-id, :name pulse-name)))) (finally (t2/delete! :model/Pulse :name pulse-name))))) - (testing "Shouldn't be able to move a Pulse to a non-normal Collection" (mt/with-temp [:model/Pulse {card-id :id}] (is (thrown-with-msg? @@ -479,13 +475,11 @@ (binding [api/*is-superuser?* true] (is (mi/can-read? subscription)) (is (mi/can-write? subscription)))) - (mt/with-current-user (mt/user->id :rasta) (binding [api/*current-user-permissions-set* (delay #{(perms/collection-read-path collection)})] (testing "A non-admin has read and write access to a subscription they created" (is (mi/can-read? subscription)) (is (mi/can-write? subscription))) - (testing "A non-admin has read-only access to a subscription they are a recipient of" ;; Create a new Dashboard Subscription with an admin creator but non-admin recipient (mt/with-temp [:model/Pulse subscription {:collection_id (u/the-id collection) @@ -496,7 +490,6 @@ :user_id (mt/user->id :rasta)}] (is (mi/can-read? subscription)) (is (not (mi/can-write? subscription))))) - (testing "A non-admin doesn't have read or write access to a subscription they aren't a creator or recipient of" (mt/with-temp [:model/Pulse subscription {:collection_id (u/the-id collection) :dashboard_id (u/the-id dashboard) @@ -524,26 +517,22 @@ (perms/set-table-permission! group-id table-id :perms/create-queries :query-builder) (perms/set-table-permission! group-id table-id :perms/download-results :no) (perms/set-table-permission! (perms/all-users-group) table-id :perms/download-results :no) - (mt/with-current-user user-id (testing "should not be able to create a pulse with CSV attachment" (is (false? (mi/can-create? :model/Pulse {:cards [(assoc card :include_csv true)] :dashboard_id nil :collection_id nil})))) - (testing "should not be able to create a pulse with XLS attachment" (is (false? (mi/can-create? :model/Pulse {:cards [(assoc card :include_xls true)] :dashboard_id nil :collection_id nil})))) - (testing "should be able to create a pulse without attachments" (is (true? (mi/can-create? :model/Pulse {:cards [card] :dashboard_id nil :collection_id nil})))))))) - (testing "A user with download permission should be able to create a pulse with attachments" (mt/with-temp [:model/User {user-id :id} {} :model/Database {db-id :id} {:engine :h2} @@ -561,14 +550,12 @@ (perms/set-database-permission! group-id db-id :perms/view-data :unrestricted) (perms/set-table-permission! group-id table-id :perms/create-queries :query-builder) (perms/set-table-permission! group-id table-id :perms/download-results :one-million-rows) - (mt/with-current-user user-id (testing "should be able to create a pulse with CSV attachment" (is (true? (mi/can-create? :model/Pulse {:cards [(assoc card :include_csv true)] :dashboard_id nil :collection_id nil})))) - (testing "should be able to create a pulse with XLS attachment" (is (true? (mi/can-create? :model/Pulse {:cards [(assoc card :include_xls true)] diff --git a/test/metabase/pulse/render/test_util.clj b/test/metabase/pulse/render/test_util.clj index d86e5807cd3e..e1d6ccdb7f82 100644 --- a/test/metabase/pulse/render/test_util.clj +++ b/test/metabase/pulse/render/test_util.clj @@ -93,10 +93,10 @@ (defn- img-node-with-svg? [loc] - (let [[tag {:keys [src]}] (zip/node loc)] - (and - (= tag :img) - (str/starts-with? src " ^String (:src attrs) (str/starts-with? " $date "2017-10-24"] @@ -411,7 +409,6 @@ {:email (fn [_ emails] (is (empty? emails)))}} - "too much data" {:card (pulse.test-util/checkins-query-card {:limit 21, :aggregation nil}) @@ -455,7 +452,6 @@ pulse.test-util/csv-attachment]}) (mt/summarize-multipart-single-email email test-card-regex #"This question has reached its goal of 5\.9\."))))}} - "no data" {:card (merge (pulse.test-util/checkins-query-card {:filter [:between $date "2014-02-01" "2014-04-01"] @@ -470,7 +466,6 @@ {:email (fn [_ emails] (is (empty? emails)))}} - "with progress bar" {:card (merge (pulse.test-util/venues-query-card "max") @@ -511,7 +506,6 @@ pulse.test-util/csv-attachment]}) (mt/summarize-multipart-single-email email test-card-regex #"This question has gone below its goal of 1\.1\."))))}} - "with no satisfying data" {:card (merge (pulse.test-util/checkins-query-card {:filter [:between $date "2014-02-10" "2014-02-12"] @@ -526,7 +520,6 @@ {:email (fn [_ emails] (is (empty? emails)))}} - "with progress bar" {:card (merge (pulse.test-util/venues-query-card "min") diff --git a/test/metabase/pulse/task/send_pulses_test.clj b/test/metabase/pulse/task/send_pulses_test.clj index c3b3f54eaeba..c2c665b701f5 100644 --- a/test/metabase/pulse/task/send_pulses_test.clj +++ b/test/metabase/pulse/task/send_pulses_test.clj @@ -27,7 +27,6 @@ (is (= 0 (t2/count :model/PulseChannel))) (is (:archived (t2/select-one :model/Pulse :id pulse-id))))) - (testing "emails" (testing "keep if has PulseChannelRecipient" (mt/with-temp [:model/Pulse {pulse-id :id} {} @@ -38,7 +37,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 1 (t2/count :model/PulseChannel))))) - (testing "keep if has external email" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id @@ -47,7 +45,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 1 (t2/count :model/PulseChannel))))) - (testing "clear if no recipients" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id @@ -55,7 +52,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 0 (t2/count :model/PulseChannel)))))) - (testing "slack" (testing "Has channel" (mt/with-temp [:model/Pulse {pulse-id :id} {} @@ -65,7 +61,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 1 (t2/count :model/PulseChannel))))) - (testing "No channel" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id @@ -74,7 +69,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 0 (t2/count :model/PulseChannel)))))) - (testing "http" (testing "do not clear if has a channel_id" (mt/with-temp [:model/Channel {channel-id :id} {:type :channel/metabase-test @@ -86,7 +80,6 @@ (#'task.send-pulses/clear-pulse-channels-no-recipients! pulse-id) (is (= 1 (t2/count :model/PulseChannel))))) - (testing "clear if there is no channel_id" (mt/with-temp [:model/Pulse {pulse-id :id} {} :model/PulseChannel _ {:pulse_id pulse-id @@ -133,7 +126,6 @@ (#'task.send-pulses/send-pulse!* pulse #{pc pc-disabled pc-no-recipient}) (testing "only send to enabled channels that has recipients" (is (= #{pc} @sent-channel-ids))) - (testing "channels that has no recipients are deleted" (is (false? (t2/exists? :model/PulseChannel pc-no-recipient))))))))) @@ -228,7 +220,6 @@ daily-at-6pm)))] (testing "sanity check that we have the correct number of triggers and no channel has been sent yet" (is (= pc-count (->> pulse-ids (map pulse-channel-test/send-pulse-triggers) (apply set/union) count)))) - (testing "make sure that all channels will be sent even though number of jobs exceed the thread pool" (u/poll {:thunk (fn [] @sent-channel-ids) :done? #(= pc-count (count %)) @@ -261,13 +252,11 @@ daily-at-6pm)] (testing "trigger exists after creation" (is (= 1 (count (pulse-channel-test/send-pulse-triggers pulse-id))))) - (testing "waiting for cron job to fire" (is (u/poll {:thunk #(pos? @send-pulse-called) :done? identity :timeout-ms 5000}) "send-pulse!* was never called - cron job may not be firing")) - (testing "alert was not sent (no call to pulse.send/send-pulse!)" (is (empty? @sent-pulse-ids)))))))))))) @@ -307,7 +296,6 @@ ;; if its want to be fired at 8 am utc+7, then it should be fired at 1am utc (is (= (next-fire-hour 1) (send-pusle-triggers-next-fire-time pulse))))) - (mt/with-temporary-setting-values [report-timezone "UTC"] (mt/with-temp [:model/Pulse {pulse :id} {} diff --git a/test/metabase/queries/models/card_test.clj b/test/metabase/queries/models/card_test.clj index 12a8a056aa84..d114334db286 100644 --- a/test/metabase/queries/models/card_test.clj +++ b/test/metabase/queries/models/card_test.clj @@ -499,7 +499,6 @@ (testing (format "target = %s" (pr-str target)) (mt/with-temp [:model/Card {card-id :id} {:parameter_mappings [{:parameter_id "_CATEGORY_NAME_" :target target}]}] - (is (= [{:parameter_id "_CATEGORY_NAME_" :target expected}] (t2/select-one-fn :parameter_mappings :model/Card :id card-id)))))))) @@ -561,7 +560,6 @@ :parameterized_object_id card-id :parameter_id "_CATEGORY_NAME_"}] (t2/select :model/ParameterCard :parameterized_object_type "card" :parameterized_object_id card-id))) - (testing "update values_source_config.card_id will update ParameterCard" (t2/update! :model/Card card-id {:parameters [(merge default-params {:values_source_type "card" @@ -571,7 +569,6 @@ :parameterized_object_id card-id :parameter_id "_CATEGORY_NAME_"}] (t2/select :model/ParameterCard :parameterized_object_type "card" :parameterized_object_id card-id)))) - (testing "delete the card will delete ParameterCard" (t2/delete! :model/Card :id card-id) (is (= [] @@ -653,20 +650,17 @@ (mt/card-with-source-metadata-for-query (mt/mbql-query products {:fields [(mt/$ids $products.title)] :limit 5}))) - (testing "ParameterCard for dashboard is removed" (is (=? [{:card_id source-card-id :parameter_id "param_1" :parameterized_object_type :card :parameterized_object_id (:id card)}] (t2/select :model/ParameterCard :card_id source-card-id)))) - (testing "update the dashboard parameter and remove values_config of dashboard" (is (=? [{:id "param_2" :name "Param 2" :type :category}] (t2/select-one-fn :parameters :model/Dashboard :id (:id dashboard)))) - (testing "but no changes with parameter on card" (is (=? [{:name "Param 1" :id "param_1" @@ -675,13 +669,10 @@ :values_source_config {:card_id source-card-id :value_field (mt/$ids $products.title)}}] (t2/select-one-fn :parameters :model/Card :id (:id card))))))) - (testing "on archive card" (t2/update! :model/Card source-card-id {:archived true}) - (testing "ParameterCard for card is removed" (is (=? [] (t2/select :model/ParameterCard :card_id source-card-id)))) - (testing "update the dashboard parameter and remove values_config of card" (is (=? [{:id "param_1" :name "Param 1" @@ -827,10 +818,8 @@ (testing "Newly created Card should know a Metabase version used to create it" (mt/with-temp [:model/Card card {}] (is (= config/mb-version-string (:metabase_version card))) - (with-redefs [config/mb-version-string "blablabla"] (t2/update! :model/Card :id (:id card) {:description "test"})) - ;; we store version of metabase which created the card (is (= config/mb-version-string (t2/select-one-fn :metabase_version :model/Card :id (:id card))))))) @@ -854,7 +843,6 @@ (let [card-with-dashboard-count (t2/hydrate (t2/select-one :model/Card :id card-id) :dashboard_count)] (testing "dashboard_count is equal to 2" (is (= 2 (:dashboard_count card-with-dashboard-count))))))) - (testing "cards with no associated dashboard" (mt/with-temp [:model/Card {card-id :id} {}] (let [card-with-dashboard-count (t2/hydrate (t2/select-one :model/Card :id card-id) :dashboard_count)] @@ -875,7 +863,6 @@ (let [card-with-usage-count (t2/hydrate (t2/select-one :model/Card :id card-id) :parameter_usage_count)] (testing "parameter_usage_count is equal to 2" (is (= 2 (:parameter_usage_count card-with-usage-count))))))) - (testing "cards not used as parameter sources" (mt/with-temp [:model/Card {card-id :id} {}] (let [card-with-usage-count (t2/hydrate (t2/select-one :model/Card :id card-id) :parameter_usage_count)] @@ -972,7 +959,6 @@ (mt/with-test-user :rasta (is (false? (mi/can-read? card))) (is (false? (mi/can-write? card)))) - (mt/with-test-user :crowberto (is (false? (mi/can-read? card))) (is (false? (mi/can-write? card))))))))) @@ -1246,7 +1232,6 @@ :dataset_query (mt/mbql-query nil {:source-table (str "card__" source-card-id)}) :collection_id remote-synced-coll-id} {:id (mt/user->id :rasta)})))) - (testing "Card without dependencies can be created in remote-synced collection" (let [card (card/create-card! {:name "Card without dependencies" @@ -1275,7 +1260,6 @@ {:card-before-update card :card-updates {:collection_id remote-synced-coll-id} :actor {:id (mt/user->id :rasta)}})))) - (testing "Card with remote-synced dependencies can be moved to remote-synced collection" (mt/with-temp [:model/Collection {another-remote-synced-coll-id :id} {:is_remote_synced true :location (str "/" remote-synced-coll-id "/")} :model/Card {remote-synced-source-card-id :id} {:collection_id another-remote-synced-coll-id @@ -1326,7 +1310,6 @@ {:card-before-update remote-synced-card :card-updates {:collection_id regular-coll-id} :actor {:id (mt/user->id :rasta)}})))) - (testing "Can move remote-synced card when no remote-synced dependents exist" (t2/delete! :model/Card :id dependent-card-id) (let [updated-card (card/update-card! @@ -1434,7 +1417,6 @@ (testing "native-query field contains only the SQL text" (is (= (-> (dummy-dataset-query (mt/id)) :native :query) (:native_query doc)))))))) - (testing "non-native queries should have nil native-query field" (mt/with-temp [:model/Card {card-id :id} {:name "Test MBQL Card" :dataset_query (mt/mbql-query venues)}] diff --git a/test/metabase/queries_rest/api/card_test.clj b/test/metabase/queries_rest/api/card_test.clj index d82def23965e..b29b8a2f466c 100644 --- a/test/metabase/queries_rest/api/card_test.clj +++ b/test/metabase/queries_rest/api/card_test.clj @@ -483,7 +483,6 @@ :collection_id (t2/select-one-pk :model/Collection :personal_owner_id (mt/user->id :crowberto))}] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "card/%d/series" card-id)))) - (is (seq? (mt/user-http-request :crowberto :get 200 (format "card/%d/series" card-id)))))) (deftest get-series-for-card-type-check-test @@ -493,7 +492,6 @@ :display "table"}] (is (= "Card with type table is not compatible to have series" (:message (mt/user-http-request :crowberto :get 400 (format "card/%d/series" card-id))))))) - (testing "404 if the card does not exsits" (is (= "Not found." (mt/user-http-request :crowberto :get 404 (format "card/%d/series" Integer/MAX_VALUE)))))) @@ -571,7 +569,6 @@ (is (true? (every? #(str/includes? % "Toad") (->> (mt/user-http-request :crowberto :get 200 (format "/card/%d/series" (:id card)) :query "toad") (map :name))))) - (testing "exclude ids works" (testing "with single id" (is (true? @@ -587,19 +584,16 @@ (->> (mt/user-http-request :crowberto :get 200 (format "/card/%d/series" (:id card)) :query "luigi" :exclude_ids (:id card7) :exclude_ids (:id card8)) (map :id))))))) - (testing "with limit and sort by id descending" (is (= ["Luigi 8" "Luigi 7" "Luigi 6" "Luigi 5"] (->> (mt/user-http-request :crowberto :get 200 (format "/card/%d/series" (:id card)) :query "luigi" :limit 4) (map :name)))) - (testing "and paging works too" (is (= ["Luigi 3" "Luigi 2" "Luigi 1"] (->> (mt/user-http-request :crowberto :get 200 (format "/card/%d/series" (:id card)) :query "luigi" :limit 10 :last_cursor (:id card4)) (map :name))))) - (testing "And returning empty list if reaches there are nothing..." (is (= [] (->> (mt/user-http-request :crowberto :get 200 (format "/card/%d/series" (:id card)) @@ -923,7 +917,7 @@ (deftest create-card-disallow-setting-embedding-type-test (testing "POST /api/card" (testing "Ignore values of `embedding_type` while creating a Card (this must be done via `PUT /api/card/:id` instead)" - ;; should be ignored regardless of the value of the `embedding-type` Setting. + ;; should be ignored regardless of the value of the `embedding-type` Setting. (doseq [embedding-type [true false]] (mt/with-temporary-setting-values [enable-embedding-static embedding-type] (mt/with-model-cleanup [:model/Card] @@ -1392,7 +1386,6 @@ :result_metadata base-metadata} (mt/user-http-request :crowberto :get 200 (str "card/" (:id card)))) "initial result_metadata is inferred correctly") - (is (=? {:type "model" :result_metadata base-metadata} (mt/user-http-request :crowberto :put 200 (str "card/" (:id card)) {:type "model"}))) @@ -1415,7 +1408,6 @@ :result_metadata base-metadata} (mt/user-http-request :crowberto :get 200 (str "card/" (:id card)))) "initial result_metadata is inferred correctly") - (is (=? {:type "model" :result_metadata base-metadata} (mt/user-http-request :crowberto :put 200 (str "card/" (:id card)) @@ -1487,7 +1479,6 @@ (testing "You have to have Collection perms to fetch a Card" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (str "card/" (u/the-id card)))))) - (testing "Should be able to fetch the Card if you have Collection read perms" (perms/grant-collection-read-permissions! (perms-group/all-users) collection) (is (=? (merge @@ -1660,7 +1651,6 @@ (mt/user-http-request :rasta :put 200 (str "card/" (u/the-id card)) {:parameters [{:id "random-id" :type "number"}]}))))) - (mt/with-temp [:model/Card card {:parameters [{:id "random-id" :type "number"}]}] (testing "nil parameters will no-op" @@ -1705,13 +1695,11 @@ (is (= "Embedding is not enabled." (mt/user-http-request :crowberto :put 400 (str "card/" (u/the-id card)) {:embedding_params {:abc "enabled"}}))))) - (mt/with-temporary-setting-values [enable-embedding-static true] (testing "Non-admin should not be allowed to update Card's embedding parms" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "card/" (u/the-id card)) {:embedding_params {:abc "enabled"}})))) - (testing "Admin should be able to update Card's embedding params" (mt/user-http-request :crowberto :put 200 (str "card/" (u/the-id card)) {:embedding_params {:abc "enabled"}}) @@ -1790,7 +1778,6 @@ (is (=? {:display "table"} (mt/user-http-request :crowberto :put 200 (str "card/" (:id card)) {:type :model})))) - (mt/with-temp [:model/Card card {:display :line}] (is (=? {:display "table"} (mt/user-http-request :crowberto :put 200 (str "card/" (:id card)) @@ -2104,7 +2091,6 @@ :expected-email-re #"Alerts about [A-Za-z]+ \(#\d+\) have stopped because the question was archived by Rasta Toucan" :f (fn [card] (mt/user-http-request :rasta :put 200 (str "card/" (u/the-id card)) {:archived true}))}) - (test-alert-deletion! {:message "Archiving a Card should trigger Alert deletion with email links when disable_links: false" :deleted? true @@ -2113,7 +2099,6 @@ :should-re-not-match? false :f (fn [card] (mt/user-http-request :rasta :put 200 (str "card/" (u/the-id card)) {:archived true}))}) - (test-alert-deletion! {:message "Archiving a Card should trigger Alert deletion without email links when disable_links: true" :deleted? true @@ -2131,7 +2116,6 @@ :expected-email-re #"Alerts about ([^<]+)<\/a> have stopped because the question was edited by Rasta Toucan" :f (fn [card] (mt/user-http-request :rasta :put 200 (str "card/" (u/the-id card)) {:display :line}))}) - (test-alert-deletion! {:message "Validate changing display type triggers alert deletion without email links when disable_links: true" :card {:display :table} @@ -2669,7 +2653,6 @@ "gets saved from one that had it -- see #9831)") (is (= {:constraints nil} (mt/user-http-request :rasta :post 200 (format "card/%d/query/csv" (u/the-id card)))))) - (testing (str "non-\"download\" queries should still get the default constraints (this also is a sanitiy " "check to make sure the `with-redefs` in the test above actually works)") (is (= {:constraints {:max-results 10, :max-results-bare-rows 10}} @@ -2764,12 +2747,10 @@ (testing "requires write permissions for the new Collection" (is (= "You don't have permissions to do that." (change-collection! 403)))) - (testing "requires write permissions for the current Collection" (perms/grant-collection-readwrite-permissions! (perms-group/all-users) new-collection) (is (= "You don't have permissions to do that." (change-collection! 403)))) - (testing "Should be able to change it once you have perms for both collections" (perms/grant-collection-readwrite-permissions! (perms-group/all-users) original-collection) (change-collection! 200) @@ -2800,7 +2781,6 @@ {:collection_id (when collection-or-collection-id-or-nil (u/the-id collection-or-collection-id-or-nil)) :card_ids (map u/the-id cards-or-card-ids)}) - :collections (collection-names cards-or-card-ids))) @@ -2878,7 +2858,7 @@ (update-card card {:description "a new description"}) (is (empty? (reviews card))))) (testing "Does not add nil moderation reviews when there are reviews but not verified" - ;; testing that we aren't just adding a nil moderation each time we update a card + ;; testing that we aren't just adding a nil moderation each time we update a card (with-card :verified (is (verified? card)) (moderation-review/create-review! {:moderated_item_id (u/the-id card) @@ -3014,19 +2994,16 @@ (mt/with-temp [:model/Card card] (is (= "Public sharing is not enabled." (mt/user-http-request :crowberto :post 400 (format "card/%d/public_link" (u/the-id card)))))))) - (mt/with-temporary-setting-values [enable-public-sharing true] (testing "Have to be an admin to share a Card" (mt/with-temp [:model/Card card] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :post 403 (format "card/%d/public_link" (u/the-id card))))))) - (testing "Cannot share an archived Card" (mt/with-temp [:model/Card card {:archived true}] (is (=? {:message "The object has been archived." :error_code "archived"} (mt/user-http-request :crowberto :post 404 (format "card/%d/public_link" (u/the-id card))))))) - (testing "Cannot share a Card that doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :post 404 (format "card/%d/public_link" Integer/MAX_VALUE)))))))) @@ -3048,7 +3025,6 @@ (testing "requires superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "card/%d/public_link" (u/the-id card)))))) - (mt/user-http-request :crowberto :delete 204 (format "card/%d/public_link" (u/the-id card))) (is (= false (t2/exists? :model/Card :id (u/the-id card), :public_uuid (:public_uuid card)))))))) @@ -3060,12 +3036,10 @@ (mt/with-temp [:model/Card card] (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "card/%d/public_link" (u/the-id card))))))) - (testing "You have to be an admin to unshare a Card" (mt/with-temp [:model/Card card (shared-card)] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "card/%d/public_link" (u/the-id card))))))) - (testing "Endpoint should 404 if Card doesn't exist" (is (= "Not found." (mt/user-http-request :crowberto :delete 404 (format "card/%d/public_link" Integer/MAX_VALUE)))))))) @@ -3082,7 +3056,6 @@ :model "Card" :model_id card-id} (mt/latest-audit-log-entry :card-public-link-created card-id))) - (testing "Does not create duplicate audit log entry when returning existing public link" (let [audit-log-count-before (count (mt/all-entries-for :card-public-link-created :model/Card @@ -3119,7 +3092,6 @@ (testing "Test that it requires superuser" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "card/public")))) - (testing "Test that superusers can fetch a list of publicly-accessible cards" (is (= [{:name true, :id true, :public_uuid true}] (for [card (mt/user-http-request :crowberto :get 200 "card/public")] @@ -3145,7 +3117,6 @@ (is (= "completed" (:status result))) (is (= 6 (count (get-in result [:data :cols])))) (is (= 1144 (count rows))) - (is (= ["AK" "Affiliate" "Doohickey" 0 18 81] (first rows))) (is (= ["MS" "Organic" "Gizmo" 0 16 42] (nth rows 445))) (is (= [nil nil nil 7 18760 69540] (last rows)))))))))) @@ -3433,7 +3404,6 @@ ["800 Degrees Neapolitan Pizzeria"] ["BCD Tofu House"]] :has_more_values true} (mt/user-http-request :rasta :get 200 (param-values-url card (:card param-keys)))))) - (testing "GET /api/card/:card-id/params/:param-key/search/:query" (is (= {:values [["Fred 62"] ["Red Medicine"]] :has_more_values false} @@ -3455,7 +3425,6 @@ :value_field (mt/$ids $venues.name)}}]}] (let [url (param-values-url card "abc")] (is (= mock-default-result (mt/user-http-request :rasta :get 200 url)))))) - (testing "if card is archived" (mt/with-temp [:model/Card {source-card-id :id} {:archived true} @@ -3614,23 +3583,19 @@ :values [["African"] ["American"] ["Asian"]]} (mt/user-http-request :rasta :get 200 (param-values-url card (:static-list param-keys))))) - (is (= {:has_more_values false, :values [["African" "Af"] ["American" "Am"] ["Asian" "As"]]} (mt/user-http-request :rasta :get 200 (param-values-url card (:static-list-label param-keys)))))) - (testing "we could search the values" (is (= {:has_more_values false, :values [["African"]]} (mt/user-http-request :rasta :get 200 (param-values-url card (:static-list param-keys) "af")))) - (is (= {:has_more_values false, :values [["African" "Af"]]} (mt/user-http-request :rasta :get 200 (param-values-url card (:static-list-label param-keys) "af"))))) - (testing "we could edit the values list" (let [card (mt/user-http-request :rasta :put 200 (str "card/" (:id card)) {:parameters [{:name "Static Category", @@ -3658,8 +3623,8 @@ {:source-table (str "card__" (u/the-id model)) :breakout [[:field "USER_ID" {:base-type :type/Integer}]] :aggregation [[:sum [:field "TOTAL" {:base-type :type/Float}]]]}) - ;; The FE sometimes used a field id instead of field by name - we need - ;; to handle this + ;; The FE sometimes used a field id instead of field by name - we need + ;; to handle this :visualization_settings {:pivot_table.column_split {:rows ["USER_ID"], :columns [], :values ["sum"]}, @@ -3888,7 +3853,6 @@ (is (not (mi/can-read? collection))) (is (not (mi/can-read? card)))) (is (blocked? (process-query)))))) - (testing "Should NOT be able to run the parent Card with valid data-perms and no collection perms" (mt/with-no-data-perms-for-all-users! (mt/with-non-admin-groups-no-collection-perms collection @@ -3898,7 +3862,6 @@ (is (not (mi/can-read? collection))) (is (not (mi/can-read? card)))) (is (blocked? (process-query)))))) - (testing "should NOT be able to run native queries with :blocked data-perms on any table" (mt/with-no-data-perms-for-all-users! (mt/with-non-admin-groups-no-collection-perms collection @@ -3908,10 +3871,8 @@ (is (mi/can-read? collection)) (is (mi/can-read? card))) (is (process-query))))) - ;; delete these in place so we can reset them below, you cannot set them twice in a row (perms/revoke-collection-permissions! (perms-group/all-users) collection) - (testing "should NOT be able to run the parent Card when data-perms and valid collection perms" (mt/with-no-data-perms-for-all-users! (mt/with-non-admin-groups-no-collection-perms collection @@ -4167,7 +4128,6 @@ (is (t2/exists? :model/DashboardCard :dashboard_id dash-id :dashboard_tab_id dt-id :card_id card-id)) (mt/user-http-request :crowberto :put 200 (str "card/" card-id) {:archived true}) (t2/delete! :model/DashboardCard :card_id card-id :dashboard_id dash-id) - (mt/user-http-request :crowberto :put 200 (str "card/" card-id) {:archived false}) (is (t2/exists? :model/DashboardCard :dashboard_id dash-id :dashboard_tab_id dt-id :card_id card-id)))) (testing "even when the card was on a tab before, it gets autoplaced to the first tab" @@ -4181,7 +4141,6 @@ (is (t2/exists? :model/DashboardCard :dashboard_id dash-id :dashboard_tab_id dt-id :card_id card-id)) (mt/user-http-request :crowberto :put 200 (str "card/" card-id) {:archived true}) (t2/delete! :model/DashboardCard :card_id card-id :dashboard_id dash-id) - (mt/user-http-request :crowberto :put 200 (str "card/" card-id) {:archived false}) (is (t2/exists? :model/DashboardCard :dashboard_id dash-id :dashboard_tab_id first-tab-id :card_id card-id))))) @@ -4369,7 +4328,7 @@ (perms/revoke-collection-permissions! (perms-group/all-users) forbidden-coll-id) (testing "We get a 403 back, because we don't have permissions" (is (= "You don't have permissions to do that." - ;; regardless of the `delete_old_dashcards` value, same response + ;; regardless of the `delete_old_dashcards` value, same response (mt/user-http-request :rasta :put 403 (str "card/" card-id "?delete_old_dashcards=true") {:dashboard_id dash-id}) (mt/user-http-request :rasta :put 403 (str "card/" card-id) {:dashboard_id dash-id})))) (testing "The card is still in the old dashboard and not the new one" @@ -4388,7 +4347,7 @@ (perms/revoke-collection-permissions! (perms-group/all-users) forbidden-coll-id) (testing "We get a 403 back, because we don't have permissions" (is (= "You don't have permissions to do that." - ;; regardless of the `delete_old_dashcards` value, same response + ;; regardless of the `delete_old_dashcards` value, same response (mt/user-http-request :rasta :put 403 (str "card/" card-id "?delete_old_dashcards=true") {:dashboard_id dash-id}) (mt/user-http-request :rasta :put 403 (str "card/" card-id) {:dashboard_id dash-id})))) (testing "The card is still in the old dashboard and not the new one" @@ -4670,7 +4629,6 @@ :card-id (:id card) :name (str "card-" (:id card)) :display-name "Card Reference"}}}) - ;; Try to update card to reference snippet (would create card → snippet → card cycle) (is (= "Cannot save card with cycles." (mt/user-http-request :crowberto :put 400 (str "card/" (:id card)) @@ -4719,7 +4677,6 @@ :card-id (:id card-2) :name (str "card-" (:id card-2)) :display-name "Card 2"}}}) - ;; Try to update card-1 to also reference snippet-2 ;; (would create card-1 → snippet-2 → card-2 → card-1 cycle) (is (= "Cannot save card with cycles." diff --git a/test/metabase/queries_rest/api/cards_test.clj b/test/metabase/queries_rest/api/cards_test.clj index a0562b8980d9..018efaaf342d 100644 --- a/test/metabase/queries_rest/api/cards_test.clj +++ b/test/metabase/queries_rest/api/cards_test.clj @@ -15,7 +15,6 @@ (is (= [{:card_id card-id :dashboards [{:name dash-name :id dash-id}]}] (mt/user-http-request :rasta :post 200 "cards/dashboards" {:card_ids [card-id]})))) - (mt/with-temp [:model/Collection {coll-id :id} {} :model/Dashboard {other-dash-id :id other-dash-name :name} {:collection_id coll-id} diff --git a/test/metabase/query_permissions/impl_test.clj b/test/metabase/query_permissions/impl_test.clj index 1813e80a568b..768eb4d149c4 100644 --- a/test/metabase/query_permissions/impl_test.clj +++ b/test/metabase/query_permissions/impl_test.clj @@ -207,7 +207,6 @@ [:field "USER_ID" {:base-type :type/Integer, :join-alias "__alias__"}]]}] :limit 10}) :throw-exceptions? true))) - (is (= {:perms/view-data {(mt/id :users) :unrestricted (mt/id :checkins) :unrestricted} :perms/create-queries {(mt/id :users) :query-builder diff --git a/test/metabase/query_processor/api_test.clj b/test/metabase/query_processor/api_test.clj index baa21212b325..90f901377054 100644 --- a/test/metabase/query_processor/api_test.clj +++ b/test/metabase/query_processor/api_test.clj @@ -791,7 +791,6 @@ (is (= "completed" (:status result))) (is (= 4 (count (get-in result [:data :cols])))) (is (= 140 (count rows))) - (is (= ["AK" "Google" 0 119] (first rows))) (is (= ["AK" "Organic" 0 89] (second rows))) (is (= ["WA" nil 2 148] (nth rows 135))) @@ -808,7 +807,6 @@ (is (= "completed" (:status result))) (is (= 4 (count (get-in result [:data :cols])))) (is (= 137 (count rows))) - (is (= ["AK" "Google" 0 27] (first rows))) (is (= ["AK" "Organic" 0 25] (second rows))) (is (= ["VA" nil 2 29] (nth rows 130))) @@ -880,7 +878,6 @@ (mt/id :people :source)]}) :values set)] (is (set/subset? #{["Doohickey"] ["Facebook"]} values)))) - (testing "search" (let [values (-> (mt/user-http-request :crowberto :post 200 "dataset/parameter/search/g" @@ -891,7 +888,6 @@ ;; results matched on g, does not include Doohickey (which is in above results) (is (set/subset? #{["Widget"] ["Google"]} values)) (is (not (contains? values ["Doohickey"]))))) - (testing "deduplicates the values returned from multiple fields" (let [values (-> (mt/user-http-request :crowberto :post 200 "dataset/parameter/values" @@ -916,7 +912,6 @@ :type :string/=, :name "Text" :id "abc"}}))))) - (testing "if value-field not found in source card" (mt/with-temp [:model/Card {source-card-id :id} {:archived true}] (is (= mock-default-result diff --git a/test/metabase/query_processor/card_test.clj b/test/metabase/query_processor/card_test.clj index 9cc0ce35b3cf..5b53ffa650b7 100644 --- a/test/metabase/query_processor/card_test.clj +++ b/test/metabase/query_processor/card_test.clj @@ -89,7 +89,6 @@ :display-name "#1234" :type :card :card-id 1234} - "xyz" {:id "xyz" :name "snippet: My Snippet" diff --git a/test/metabase/query_processor/cast_test.clj b/test/metabase/query_processor/cast_test.clj index 522a276f6148..64950382f42b 100644 --- a/test/metabase/query_processor/cast_test.clj +++ b/test/metabase/query_processor/cast_test.clj @@ -828,7 +828,6 @@ :mode nil :expected #{"2025-05-15T22:20:01Z" "2025-05-15 22:20:01"}} - ;; iso mode {:expression (lib/concat "2025-05-15T22:20:01" "") :mode :iso @@ -838,7 +837,6 @@ :mode :iso :expected #{"2025-05-15T22:20:01Z" "2025-05-15 22:20:01"}} - ;; simple mode {:expression (lib/concat "20250515222001" "") :mode :simple diff --git a/test/metabase/query_processor/coercion_test.clj b/test/metabase/query_processor/coercion_test.clj index 1efef3d27eb3..329e0c045a4f 100644 --- a/test/metabase/query_processor/coercion_test.clj +++ b/test/metabase/query_processor/coercion_test.clj @@ -46,7 +46,6 @@ (qp/process-query query)) (mt/rows) ffirst)] - (is (or (integer? coerced-number) (instance? BigDecimal coerced-number))) (is (= res @@ -71,7 +70,6 @@ (qp/process-query query)) (mt/rows) ffirst)] - (is (or (integer? coerced-number) (instance? BigDecimal coerced-number))) (is (= res diff --git a/test/metabase/query_processor/dashboard_test.clj b/test/metabase/query_processor/dashboard_test.clj index 8e3512ab19d2..e13170b23c4e 100644 --- a/test/metabase/query_processor/dashboard_test.clj +++ b/test/metabase/query_processor/dashboard_test.clj @@ -98,25 +98,20 @@ :model/DashboardCardSeries _ {:dashboardcard_id dashcard-id-3 :card_id card-id-3}] (testing "Sanity check that a valid combination card, dashcard and dashboard IDs executes successfully" (is (= 100 (count (mt/rows (run-query-for-dashcard dashboard-id card-id-1 dashcard-id-1)))))) - (testing "A 404 error should be thrown if the card-id is not valid for the dashboard" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found" (run-query-for-dashcard dashboard-id (* card-id-1 2) dashcard-id-1)))) - (testing "A 404 error should be thrown if the dashcard-id is not valid for the dashboard" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found" (run-query-for-dashcard dashboard-id card-id-1 (* dashcard-id-1 2))))) - (testing "A 404 error should be thrown if the dashcard-id is not valid for the card" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found" (run-query-for-dashcard dashboard-id card-id-1 dashcard-id-2)))) - (testing "Sanity check that a card-id in a dashboard card series executes successfully" (is (= 100 (count (mt/rows (run-query-for-dashcard dashboard-id card-id-3 dashcard-id-3)))))) - (testing "A 404 error should be thrown if the card-id is not valid for the dashcard series" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found" diff --git a/test/metabase/query_processor/date_bucketing_test.clj b/test/metabase/query_processor/date_bucketing_test.clj index 762c404fe989..4a27318f9ab9 100644 --- a/test/metabase/query_processor/date_bucketing_test.clj +++ b/test/metabase/query_processor/date_bucketing_test.clj @@ -1371,7 +1371,7 @@ (deftest ^:parallel relative-time-interval-test (mt/test-drivers (mt/normal-drivers-with-feature :date-arithmetics :test/dynamic-dataset-loading) - ;; Following verifies #45942 is solved. Changing the offset ensures that intervals do not overlap. + ;; Following verifies #45942 is solved. Changing the offset ensures that intervals do not overlap. (testing "Syntactic sugar (`:relative-time-interval` clause) (#45942)" (mt/dataset checkins:1-per-day:60 (is (= 7 diff --git a/test/metabase/query_processor/date_time_zone_functions_test.clj b/test/metabase/query_processor/date_time_zone_functions_test.clj index e18ea61c0794..ad170cd9fab7 100644 --- a/test/metabase/query_processor/date_time_zone_functions_test.clj +++ b/test/metabase/query_processor/date_time_zone_functions_test.clj @@ -271,23 +271,19 @@ :query {:expressions {"expr" [:abs [:get-year [:field (mt/id :times :dt) nil]]]} :filter [:= [:field (mt/id :times :index) nil] 1] :fields [[:expression "expr"]]}} - {:title "Nested with arithmetic" :expected [4008] :query {:expressions {"expr" [:* [:get-year [:field (mt/id :times :dt) nil]] 2]} :filter [:= [:field (mt/id :times :index) nil] 1] :fields [[:expression "expr"]]}} - {:title "Filter using the extracted result - equality" :expected [1] :query {:filter [:= [:get-year [:field (mt/id :times :dt) nil]] 2004] :fields [[:field (mt/id :times :index) nil]]}} - {:title "Filter using the extracted result - comparable" :expected [1] :query {:filter [:< [:get-year [:field (mt/id :times :dt) nil]] 2005] :fields [[:field (mt/id :times :index) nil]]}} - {:title "Nested expression in fitler" :expected [1] :query {:filter [:= [:* [:get-year [:field (mt/id :times :dt) nil]] 2] 4008] @@ -304,7 +300,6 @@ :query {:expressions {"expr" [:abs [:get-year [:+ [:field (mt/id :times :dt) nil] [:interval 1 :year]]]]} :filter [:= [:field (mt/id :times :index) nil] 1] :fields [[:expression "expr"]]}} - {:title "Interval addition nested in numeric addition" :expected [2006] :query {:expressions {"expr" [:+ [:get-year [:+ [:field (mt/id :times :dt) nil] [:interval 1 :year]]] 1]} @@ -598,12 +593,10 @@ :expected [2006 2010 2014] :query {:expressions {"expr" [:get-year [:datetime-add [:field (mt/id :times :dt) nil] 2 :year]]} :fields [[:expression "expr"]]}} - {:title "Nested date math twice" :expected ["2006-05-19 09:19:09" "2010-08-20 10:20:10" "2015-01-21 11:21:11"] :query {:expressions {"expr" [:datetime-add [:datetime-add [:field (mt/id :times :dt) nil] 2 :year] 2 :month]} :fields [[:expression "expr"]]}} - {:title "filter with date math" :expected [1] :query {:filter [:= [:get-year [:datetime-add [:field (mt/id :times :dt) nil] 2 :year]] 2006] @@ -1047,7 +1040,6 @@ (let [a-str "2022-10-02T01:00:00+01:00" ; 2022-10-01T23:00:00-01:00 <- datetime in report-timezone offset b-str "2022-10-03T00:00:00Z" units [:second :minute :hour :day :week :month :quarter :year]] - (->> (mt/run-mbql-query times {:filter [:and [:= a-str $a_dt_tz_text] [:= b-str $b_dt_tz_text]] :expressions (into {} (for [unit units] diff --git a/test/metabase/query_processor/distinct_where_test.clj b/test/metabase/query_processor/distinct_where_test.clj index 3cbc8863e6c5..4321c745a629 100644 --- a/test/metabase/query_processor/distinct_where_test.clj +++ b/test/metabase/query_processor/distinct_where_test.clj @@ -15,7 +15,6 @@ mt/rows ffirst int))) - (testing "Should get normalized correctly and work as expected" (is (= 3 (->> {:aggregation [["distinct-where" diff --git a/test/metabase/query_processor/expression_aggregations_test.clj b/test/metabase/query_processor/expression_aggregations_test.clj index 48d2abdbeee9..7f49e66d7604 100644 --- a/test/metabase/query_processor/expression_aggregations_test.clj +++ b/test/metabase/query_processor/expression_aggregations_test.clj @@ -257,7 +257,6 @@ (mt/run-mbql-query venues {:aggregation [[:aggregation-options [:sum [:+ $price 1]] {:name "sum_of_price"}]] :breakout [$price]})))))) - (testing "check that we can name an expression aggregation w/ expression at top-level" (is (= {:rows [[1 -19] [2 77] diff --git a/test/metabase/query_processor/field_visibility_test.clj b/test/metabase/query_processor/field_visibility_test.clj index c1ea23cd28ae..59325abe0c31 100644 --- a/test/metabase/query_processor/field_visibility_test.clj +++ b/test/metabase/query_processor/field_visibility_test.clj @@ -21,7 +21,6 @@ (testing "sanity check -- everything should be returned before making changes" (is (=? (m/index-by :id (qp.test-util/expected-cols :venues)) (m/index-by :id (venues-cols-from-query))))) - (testing ":details-only fields should not be returned in normal queries" (tu/with-temp-vals-in-db :model/Field (mt/id :venues :price) {:visibility_type :details-only} (is (=? (m/index-by :id (for [col (qp.test-util/expected-cols :venues)] diff --git a/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj b/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj index cd912d8969d0..2aa4cbaf4a77 100644 --- a/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj +++ b/test/metabase/query_processor/middleware/add_implicit_clauses_test.clj @@ -357,7 +357,6 @@ {:fields [{:id (mt/id :venues :price) :coercion-strategy :Coercion/UNIXSeconds->DateTime :effective-type :type/Instant}]})] - (is (=? {:status :completed} (qp/process-query (assoc query :lib/metadata mp')))))) diff --git a/test/metabase/query_processor/middleware/add_remaps_test.clj b/test/metabase/query_processor/middleware/add_remaps_test.clj index 8cef2a584ab1..0e7a06e7d10c 100644 --- a/test/metabase/query_processor/middleware/add_remaps_test.clj +++ b/test/metabase/query_processor/middleware/add_remaps_test.clj @@ -233,7 +233,6 @@ {"apple" "Appletini" "banana" "Bananasplit" "kiwi" "Kiwi-flavored Thing"}) - (is (=? {:status :completed :row_count 3 :data {:rows [[1 "apple" 4 3 "Appletini"] diff --git a/test/metabase/query_processor/middleware/binning_test.clj b/test/metabase/query_processor/middleware/binning_test.clj index 6fefc1c0d4e1..cb1ff8989311 100644 --- a/test/metabase/query_processor/middleware/binning_test.clj +++ b/test/metabase/query_processor/middleware/binning_test.clj @@ -27,7 +27,6 @@ [[:and [:= {} [:field {} 1] 10] [:= {} [:field {} 2] 10]]])))) - (is (=? {1 [[:< {} [:field {} 1] 10] [:> {} [:field {} 1] 1]] 2 [[:> {} [:field {} 2] 20] [:< {} [:field {} 2] 10]] 3 [[:between {} [:field {} 3] 5 10]]} diff --git a/test/metabase/query_processor/middleware/cache_backend/db_test.clj b/test/metabase/query_processor/middleware/cache_backend/db_test.clj index df7819451bd4..9ab64fee65f3 100644 --- a/test/metabase/query_processor/middleware/cache_backend/db_test.clj +++ b/test/metabase/query_processor/middleware/cache_backend/db_test.clj @@ -8,7 +8,9 @@ [metabase.query-processor.middleware.cache-backend.interface :as i] [metabase.test :as mt] [metabase.util.encryption-test :as encryption-test]) - (:import (java.sql Connection))) + (:import + (java.sql Connection) + (java.util.concurrent CountDownLatch TimeUnit))) (set! *warn-on-reflection* true) @@ -38,3 +40,43 @@ (let [cached (codecs/bytes->str (cache-results conn))] (is (str/starts-with? cached "AES/CBC/PKCS5Padding")) (is (not (str/includes? cached "cache-value"))))))))) + +(deftest save-results-concurrent-race-test + (testing "Concurrent save-results! calls with the same query hash should not violate the PK constraint (#73770)" + (mt/with-temp-empty-app-db [_conn :h2] + (mdb/setup-db! :create-sample-content? false) + ;; Real query hashes are 32-byte SHA-256 digests; the query_cache PK is BINARY(32). With a + ;; shorter hash, H2 pads stored values with zeros, so a subsequent select-by-hash would not + ;; match — masking the retry logic in update-or-insert!. + (let [query-hash (byte-array 32 (map byte (concat (.getBytes "race-test-hash") (repeat 0)))) + results-bytes (codecs/to-bytes "result-bytes") + ;; Force both threads into the INSERT call simultaneously, so they both attempt to + ;; insert the same primary key. The latch wraps both t2 insert variants so the race + ;; is deterministically exercised regardless of which one the implementation uses. + latch (CountDownLatch. 2) + ins-pks-var (requiring-resolve 'toucan2.core/insert-returning-pk!) + ins-instances-var (requiring-resolve 'toucan2.core/insert-returning-instances!) + orig-ins-pks @ins-pks-var + orig-ins-instances @ins-instances-var + await-race! (fn [model] + (when (= model :model/QueryCache) + (.countDown latch) + (.await latch 5 TimeUnit/SECONDS))) + coordinated-ins-pks (fn [& args] + (await-race! (first args)) + (apply orig-ins-pks args)) + coordinated-ins-inst (fn [& args] + (await-race! (first args)) + (apply orig-ins-instances args)) + cache-backend (i/cache-backend :db)] + #_{:clj-kondo/ignore [:metabase/prefer-with-dynamic-fn-redefs]} + (with-redefs-fn {ins-pks-var coordinated-ins-pks + ins-instances-var coordinated-ins-inst} + (fn [] + (mt/with-log-messages-for-level [messages :error] + (mt/repeat-concurrently 2 #(i/save-results! cache-backend query-hash results-bytes)) + (testing "both threads reached the coordination point (otherwise the race wasn't exercised)" + (is (zero? (.getCount latch)))) + (testing "no \"Error saving query results to cache.\" should be logged" + (is (empty? (filter #(some-> % :message (str/includes? "Error saving query results to cache")) + (messages)))))))))))) diff --git a/test/metabase/query_processor/middleware/cache_test.clj b/test/metabase/query_processor/middleware/cache_test.clj index d53385640866..1e4570f26655 100644 --- a/test/metabase/query_processor/middleware/cache_test.clj +++ b/test/metabase/query_processor/middleware/cache_test.clj @@ -273,7 +273,6 @@ (mt/wait-for-result save-chan))) (is (= :not-cached (run-query)))))) - (testing "...but if it takes *longer* than the min TTL, it should be cached" (with-mock-cache! [save-chan] (binding [*query-caching-min-ttl* 0.1] @@ -322,7 +321,7 @@ (let [query (mt/native-query {:query (tx/native-array-query driver/*driver*)}) query (assoc query :cache-strategy (ttl-strategy)) original-result (qp/process-query query) - ;; clear any existing values in the `save-chan` + ;; clear any existing values in the `save-chan` _ (while (a/poll! save-chan)) _ (mt/wait-for-result save-chan) cached-result (qp/process-query query)] @@ -359,7 +358,7 @@ (let [query (mt/native-query {:query (format "SELECT 'foo'::%s;" dom-name)}) query (assoc query :cache-strategy (ttl-strategy)) original-result (qp/process-query query) - ;; clear any existing values in the `save-chan` + ;; clear any existing values in the `save-chan` _ (while (a/poll! save-chan)) _ (mt/wait-for-result save-chan) cached-result (qp/process-query query)] diff --git a/test/metabase/query_processor/middleware/catch_exceptions_test.clj b/test/metabase/query_processor/middleware/catch_exceptions_test.clj index 5ede3a51a0fc..ce37f8ac3063 100644 --- a/test/metabase/query_processor/middleware/catch_exceptions_test.clj +++ b/test/metabase/query_processor/middleware/catch_exceptions_test.clj @@ -179,7 +179,6 @@ (qp/process-query (qp/userland-query (mt/mbql-query venues {:fields [!month.id]}))))))) - (testing "They should see it if they have ad-hoc native query perms" (data-perms/set-database-permission! (perms-group/all-users) (mt/id) :perms/view-data :unrestricted) (data-perms/set-database-permission! (perms-group/all-users) (mt/id) :perms/create-queries :query-builder-and-native) diff --git a/test/metabase/query_processor/middleware/fetch_source_query_test.clj b/test/metabase/query_processor/middleware/fetch_source_query_test.clj index f5b2e41819a2..49e9dbe557d7 100644 --- a/test/metabase/query_processor/middleware/fetch_source_query_test.clj +++ b/test/metabase/query_processor/middleware/fetch_source_query_test.clj @@ -128,8 +128,7 @@ (mt/with-temp-env-var-value! ["MB_ENABLE_NESTED_QUERIES" "false"] (is (false? (lib-be/enable-nested-queries)))) (qp.store/with-metadata-provider (mock-metadata-provider) - -;; resolve-source-cards doesn't respect [[mt/with-temp-env-var-value!]], so set it inside the thunk: + ;; resolve-source-cards doesn't respect [[mt/with-temp-env-var-value!]], so set it inside the thunk: (is (thrown-with-msg? Exception #"Nested queries are disabled" (resolve-source-cards diff --git a/test/metabase/query_processor/middleware/format_rows_test.clj b/test/metabase/query_processor/middleware/format_rows_test.clj index d4d39313d9fa..25133152dfa0 100644 --- a/test/metabase/query_processor/middleware/format_rows_test.clj +++ b/test/metabase/query_processor/middleware/format_rows_test.clj @@ -106,79 +106,60 @@ [[(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "Asia/Tokyo")) "2011-04-17T15:00:00Z" "UTC"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "Asia/Tokyo")) "2011-04-18T00:00:00+09:00" "Asia/Tokyo"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "UTC")) "2011-04-18T09:00:00+09:00" "Asia/Tokyo"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "UTC")) "2011-04-18T00:00:00Z" "UTC"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 9)) "2011-04-17T15:00:00Z" "UTC"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 9)) "2011-04-18T00:00:00+09:00" "Asia/Tokyo"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0)) "2011-04-18T09:00:00+09:00" "Asia/Tokyo"] - [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) "2011-04-18T00:00:00Z" "UTC"] - [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) "2011-04-18T09:00:00+09:00" "Asia/Tokyo"] - [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) "2011-04-18T00:00:00Z" "UTC"] - [(t/local-date-time 2011 4 18 0 0 0 0) "2011-04-18T00:00:00+09:00" "Asia/Tokyo"] - [(t/local-date-time 2011 4 18 0 0 0 0) "2011-04-18T00:00:00Z" "UTC"] - [(t/local-date 2011 4 18) "2011-04-18T00:00:00+09:00" "Asia/Tokyo"] - [(t/local-date 2011 4 18) "2011-04-18T00:00:00Z" "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 9)) "10:55:00Z" "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 9)) "19:55:00+09:00" "Asia/Tokyo"] - [(t/offset-time 19 55 0 0 (t/zone-offset 0)) "19:55:00Z" "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 0)) "04:55:00+09:00" "Asia/Tokyo"] - [(t/local-time 19 55) "19:55:00Z" "UTC"] - [(t/local-time 19 55) "19:55:00+09:00" "Asia/Tokyo"]]] diff --git a/test/metabase/query_processor/middleware/limit_test.clj b/test/metabase/query_processor/middleware/limit_test.clj index 1584aaa7f1d1..547e32e1b8c7 100644 --- a/test/metabase/query_processor/middleware/limit_test.clj +++ b/test/metabase/query_processor/middleware/limit_test.clj @@ -137,8 +137,8 @@ (get-in (add-default-limit {:type :query :query {:source-table (meta/id :venues)} - ;; setting a constraint here will result in `(mbql.u/query->max-rows-limit query)` returning that limit - ;; so we can use this to check the behaviour of `add-default-limit` when download-row-limit is unset + ;; setting a constraint here will result in `(mbql.u/query->max-rows-limit query)` returning that limit + ;; so we can use this to check the behaviour of `add-default-limit` when download-row-limit is unset :constraints (when limit {:max-results-bare-rows limit}) :info {:context context}}) [:query :limit]))))))) diff --git a/test/metabase/query_processor/middleware/metrics_test.clj b/test/metabase/query_processor/middleware/metrics_test.clj index 36a8dcfb1c3b..8ac6d0f78785 100644 --- a/test/metabase/query_processor/middleware/metrics_test.clj +++ b/test/metabase/query_processor/middleware/metrics_test.clj @@ -712,7 +712,6 @@ (lib.tu.macros/mbql-query checkins {:joins [{:condition [:= [:field (meta/id :checkins :id) nil] 2] :source-query before}]}))))) - (testing "inside :joins inside :source-query" (is (=? (lib.tu.macros/mbql-query nil {:source-query {:source-table (meta/id :checkins) diff --git a/test/metabase/query_processor/middleware/parameters_test.clj b/test/metabase/query_processor/middleware/parameters_test.clj index 4067c59d63b7..1a86fa45b84b 100644 --- a/test/metabase/query_processor/middleware/parameters_test.clj +++ b/test/metabase/query_processor/middleware/parameters_test.clj @@ -198,7 +198,6 @@ (deftest ^:parallel expand-multiple-referenced-cards-in-template-tags (testing "multiple sub-queries, referenced in template tags, are correctly substituted" - (is (=? (native-query {:query "SELECT COUNT(*) FROM (SELECT 1) AS c1, (SELECT 2) AS c2", :params []}) (substitute-params diff --git a/test/metabase/query_processor/middleware/permissions_test.clj b/test/metabase/query_processor/middleware/permissions_test.clj index 6b2685772d3d..23a90d106da7 100644 --- a/test/metabase/query_processor/middleware/permissions_test.clj +++ b/test/metabase/query_processor/middleware/permissions_test.clj @@ -283,7 +283,6 @@ {:database (u/the-id db) :type :query :query {:source-table (u/the-id table)}})))) - ;; Don't leak metadata about the table if the user doesn't have access to it, even if it's inactive (mt/with-no-data-perms-for-all-users! (is (thrown-with-msg? @@ -321,19 +320,16 @@ (is (= expected (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card -> Source Query]" (is (= expected (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (= expected (mt/rows @@ -373,19 +369,16 @@ (is (= expected (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card -> Source Query]" (is (= expected (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (= expected (mt/rows @@ -424,19 +417,16 @@ (is (= expected (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card -> Source Query]" (is (= expected (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (= expected (mt/rows @@ -479,19 +469,16 @@ (is (= expected (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card -> Source Query]" (is (= expected (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card -> Card -> Source Query]" (is (= expected (mt/rows @@ -530,13 +517,11 @@ #"You do not have permissions to view Card" (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should not be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card 1 -> Source Query]" (is (thrown-with-msg? ExceptionInfo @@ -544,7 +529,6 @@ (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card 2 -> Card 1 -> Source Query]" (is (= expected (mt/rows @@ -587,13 +571,11 @@ #"You do not have permissions to view Card" (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should not be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card 1 -> Source Query]" (is (thrown-with-msg? ExceptionInfo @@ -601,7 +583,6 @@ (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card 2 -> Card 1 -> Source Query]" (is (= expected (mt/rows @@ -643,13 +624,11 @@ #"You do not have permissions to view Card" (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should not be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card 1 -> Source Query]" (is (thrown-with-msg? ExceptionInfo @@ -657,7 +636,6 @@ (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card 2 -> Card 1 -> Source Query]" (is (= expected (mt/rows @@ -703,13 +681,11 @@ #"You do not have permissions to view Card" (mt/rows (qp/process-query (:dataset_query card-1))))))) - (testing "Should be able to run Card 2 directly [Card 2 -> Card 1 -> Source Query]" (binding [qp.perms/*card-id* (u/the-id card-2)] (is (= expected (mt/rows (qp/process-query (:dataset_query card-2))))))) - (testing "Should not be able to run ad-hoc query with Card 1 as source query [Ad-hoc -> Card 1 -> Source Query]" (is (thrown-with-msg? ExceptionInfo @@ -717,7 +693,6 @@ (mt/rows (qp/process-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id)})))))) - (testing "Should be able to run ad-hoc query with Card 2 as source query [Ad-hoc -> Card 2 -> Card 1 -> Source Query]" (is (= expected (mt/rows @@ -842,7 +817,7 @@ :alias "v" :source-query {:native "SELECT * from orders"} :condition [:= true true] - ;; Make sure we can't just pass in this key and join to arbitrary SQL! + ;; Make sure we can't just pass in this key and join to arbitrary SQL! :qp/stage-is-from-source-card card-id}] :order-by [[:asc $id]] :limit 2})] @@ -1260,7 +1235,6 @@ ;; Create an ad-hoc query that uses the source card and adds another aggregation stage ;; Should successfully run the multi-stage aggregation query (is (= expected (mt/rows (qp/process-query (qp/userland-query multi-stage-query))))))) - (testing "Should NOT be able to run the same query if source card permissions are revoked" ;; Remove collection permissions (perms/revoke-collection-permissions! (perms/all-users-group) collection) @@ -1309,20 +1283,17 @@ (binding [qp.perms/*card-id* card-3-id] (is (= expected (mt/rows (qp/process-query card-3-query)))))) - (testing "Should be able to run ad-hoc query using Card 3 as source" (let [ad-hoc-query (mt/mbql-query nil {:source-table (format "card__%d" card-3-id)})] (is (= expected (mt/rows (qp/process-query (qp/userland-query ad-hoc-query))))))) - (testing "Should be able to run ad-hoc query using Card 2 as source" (let [ad-hoc-query (mt/mbql-query nil {:source-table (format "card__%d" card-2-id) :limit 1})] (is (= expected (mt/rows (qp/process-query (qp/userland-query ad-hoc-query))))))) - (testing "Should be able to run ad-hoc query using Card 1 as source" (let [ad-hoc-query (mt/mbql-query nil {:source-table (format "card__%d" card-1-id) @@ -1331,7 +1302,6 @@ :limit 1})] (is (= expected (mt/rows (qp/process-query (qp/userland-query ad-hoc-query))))))) - (testing "Blocked table (reviews) should still be inaccessible" (is (thrown-with-msg? ExceptionInfo @@ -1379,7 +1349,6 @@ (binding [qp.perms/*card-id* card-4-id] (is (= expected (mt/rows (qp/process-query card-4-query)))))) - (testing "Should be able to run ad-hoc query using Card 4 as source" (is (= expected (mt/rows @@ -1401,7 +1370,6 @@ ;; Grant permissions to collections 1 and 2, but not the restricted collection (perms/grant-collection-read-permissions! (perms/all-users-group) accessible-coll-1) (perms/grant-collection-read-permissions! (perms/all-users-group) accessible-coll-2) - ;; Card 1: In accessible collection (let [card-1-query (mt/mbql-query venues {:fields [$id $name $price] @@ -1434,24 +1402,20 @@ (is (= [[1 "Red Medicine" 3] [2 "Stout Burgers & Beers" 2]] (mt/rows (qp/process-query card-1-query)))))) - (testing "Should NOT be able to run Card 2 directly (no access)" (binding [qp.perms/*card-id* card-2-id] (is (thrown-with-msg? ExceptionInfo #"You do not have permissions to view Card" (qp/process-query card-2-query))))) - (testing "Should be able to run Card 3 directly (despite Card 2 in chain)" (binding [qp.perms/*card-id* card-3-id] (is (= expected (mt/rows (qp/process-query card-3-query)))))) - (testing "Should be able to run Card 4 directly (despite Card 2 in chain)" (binding [qp.perms/*card-id* card-4-id] (is (= expected (mt/rows (qp/process-query card-4-query)))))) - (testing "Should NOT be able to run ad-hoc query with Card 2 as source" (is (thrown-with-msg? ExceptionInfo @@ -1475,7 +1439,6 @@ ;; Grant permissions only to specific collections (perms/grant-collection-read-permissions! (perms/all-users-group) parent-coll) (perms/grant-collection-read-permissions! (perms/all-users-group) grandchild-coll) - ;; Card 1: In parent collection (accessible) (let [card-1-query (mt/mbql-query venues {:fields [$id $name $category_id] @@ -1498,18 +1461,15 @@ (testing "Should be able to run Card 1 in parent collection" (binding [qp.perms/*card-id* card-1-id] (is (seq (mt/rows (qp/process-query card-1-query)))))) - (testing "Should not be able to run Card 2 (does not inherit permissions from parent)" (is (thrown-with-msg? ExceptionInfo #"You do not have permissions to view Card" (binding [qp.perms/*card-id* card-2-id] (mt/rows (qp/process-query card-2-query)))))) - (testing "Should be able to run Card 3 in grandchild collection" (binding [qp.perms/*card-id* card-3-id] (is (seq (mt/rows (qp/process-query card-3-query)))))) - (testing "Should be able to run ad-hoc query chaining all cards" (is (seq (mt/rows @@ -1531,7 +1491,6 @@ ;; Grant permissions to collections 1 and 3 only (perms/grant-collection-read-permissions! (perms/all-users-group) coll-1) (perms/grant-collection-read-permissions! (perms/all-users-group) coll-3) - ;; Card 1: Native query in accessible collection (let [card-1-query (mt/native-query {:query (str "SELECT id, name, category_id, price " @@ -1561,19 +1520,16 @@ (testing "Should be able to run Card 1 (native, accessible)" (binding [qp.perms/*card-id* card-1-id] (is (= 2 (count (mt/rows (qp/process-query card-1-query))))))) - (testing "Should NOT be able to run Card 2 directly (restricted collection)" (binding [qp.perms/*card-id* card-2-id] (is (thrown-with-msg? ExceptionInfo #"You do not have permissions to view Card" (qp/process-query card-2-query))))) - (testing "Should be able to run Card 3 (references restricted Card 2 via template tag)" (binding [qp.perms/*card-id* card-3-id] (is (= expected (mt/rows (qp/process-query card-3-query)))))) - (testing "Should be able to use Card 3 as source in ad-hoc query" (is (= expected (mt/rows @@ -1592,7 +1548,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :venues) :perms/create-queries :query-builder) ;; Block access to categories table (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use expressions with foreign key fields even if the referenced table is blocked" (let [result (qp/process-query @@ -1604,7 +1559,6 @@ (is (= [[1 "Red Medicine" 5] [2 "Stout Burgers & Beers" 12]] (mt/rows result))))) - (testing "Should be able to use expressions with only accessible fields" (let [result (qp/process-query (mt/mbql-query venues @@ -1631,7 +1585,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to calculate across permitted tables" (let [result (qp/process-query @@ -1642,7 +1595,6 @@ :order-by [[:desc [:expression "checkin_rate"]]] :limit 2}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to create aggregations with joins to restricted tables" (is (thrown-with-msg? ExceptionInfo @@ -1666,7 +1618,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :venues) :perms/create-queries :query-builder) (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/view-data :unrestricted) (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/create-queries :query-builder) - (mt/with-test-user :rasta (testing "Should be able to use expressions in join conditions between permitted tables" (let [result (qp/process-query @@ -1683,7 +1634,6 @@ :order-by [[:asc $id]] :limit 3}))] (is (= 3 (count (mt/rows result)))))) - (testing "Should be able to use expressions in filters" (let [result (qp/process-query (mt/mbql-query venues @@ -1703,11 +1653,9 @@ (mt/with-no-data-perms-for-all-users! (perms/set-database-permission! (perms/all-users-group) (mt/id) :perms/view-data :unrestricted) (perms/set-database-permission! (perms/all-users-group) (mt/id) :perms/create-queries :no) - (mt/with-temp [:model/Collection {accessible-coll :id} {} :model/Collection {restricted-coll :id} {}] (perms/grant-collection-read-permissions! (perms/all-users-group) accessible-coll) - ;; Create a card with expressions (let [card-query (mt/mbql-query venues {:expressions {"price_tier" [:case @@ -1721,7 +1669,6 @@ :dataset_query card-query} :model/Card {restricted-card-id :id} {:collection_id restricted-coll :dataset_query card-query}] - (mt/with-test-user :rasta (testing "Should be able to add expressions to accessible card source" (let [result (qp/process-query @@ -1738,7 +1685,6 @@ [:expression "tier_name"]] :limit 2}))] (is (= 2 (count (mt/rows result)))))) - (testing "Should NOT be able to query restricted card even with expressions" (is (thrown-with-msg? ExceptionInfo @@ -1759,7 +1705,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to reviews table (perms/set-table-permission! (perms/all-users-group) (mt/id :reviews) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use cumulative aggregations on accessible tables" (let [result (qp/process-query @@ -1769,7 +1714,6 @@ :order-by [[:asc [:field (mt/id :checkins :date) {:temporal-unit :month}]]] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to use cumulative aggregations on restricted tables" (is (thrown-with-msg? ExceptionInfo @@ -1792,7 +1736,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :venues) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use multiple aggregations on accessible tables with joins" (let [result (qp/process-query @@ -1807,7 +1750,6 @@ :breakout [[:field (mt/id :venues :category_id) {:join-alias "v"}]] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to join to restricted table in aggregation query" (is (thrown-with-msg? ExceptionInfo @@ -1832,7 +1774,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use aggregations with filters on accessible fields" (let [result (qp/process-query @@ -1842,7 +1783,6 @@ :filter [:> $venue_id 5] ; Filter condition :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to use aggregations on restricted tables" ;; This tests aggregations on blocked tables (is (thrown-with-msg? @@ -1865,7 +1805,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to reviews table (which might contain sensitive rating data) (perms/set-table-permission! (perms/all-users-group) (mt/id :reviews) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to calculate shares from accessible data" (let [result (qp/process-query @@ -1873,7 +1812,6 @@ {:aggregation [[:share [:> $venue_id 50]]] ; Share of checkins for venues > 50 :limit 1}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to calculate shares from restricted sensitive data" (is (thrown-with-msg? ExceptionInfo @@ -1895,7 +1833,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :venues) :perms/create-queries :query-builder) ;; Block access to categories table (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to aggregate with expressions across allowed joined tables" (let [result (qp/process-query @@ -1910,7 +1847,6 @@ :breakout [[:field (mt/id :venues :name) {:join-alias "v"}]] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to aggregate across joins to restricted tables" (is (thrown-with-msg? ExceptionInfo @@ -1935,7 +1871,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to reviews table (perms/set-table-permission! (perms/all-users-group) (mt/id :reviews) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use basic aggregations on accessible tables" ;; Note: Window functions aren't fully supported in the test MBQL syntax, @@ -1947,7 +1882,6 @@ :order-by [[:desc [:aggregation 0]]] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to aggregate on restricted tables" (is (thrown-with-msg? ExceptionInfo @@ -1973,7 +1907,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/create-queries :no) ;; Block access to users table completely (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to join venues and checkins (both allowed)" (let [result (qp/process-query @@ -1985,7 +1918,6 @@ :fields [$id $name] :limit 2}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to do three-way join with blocked users table" (is (thrown-with-msg? ExceptionInfo @@ -2003,7 +1935,6 @@ [:field (mt/id :users :id) {:join-alias "u"}]]}] :fields [$id $name] :limit 2}))))) - (testing "Should NOT be able to do three-way join with create-queries blocked categories table" (is (thrown-with-msg? ExceptionInfo @@ -2031,7 +1962,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :venues) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to self-join on accessible table" (let [result (qp/process-query @@ -2044,7 +1974,6 @@ :filter [:!= $id [:field (mt/id :venues :id) {:join-alias "v2"}]] ; Different venues :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to self-join on restricted table" (is (thrown-with-msg? ExceptionInfo @@ -2070,7 +1999,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to join using expressions when both tables are accessible" (let [result (qp/process-query @@ -2084,7 +2012,6 @@ :fields [$id $name [:expression "venue_cat_calc"]] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to join using expressions when one table is restricted" (is (thrown-with-msg? ExceptionInfo @@ -2113,7 +2040,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :categories) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use right join with accessible tables" (let [result (qp/process-query @@ -2126,7 +2052,6 @@ :fields [$id $name] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to use right join with restricted table" (is (thrown-with-msg? ExceptionInfo @@ -2140,7 +2065,6 @@ :strategy :right-join}] :fields [$id $name] :limit 3}))))) - (testing "Should be able to use left join (instead of full outer) with accessible tables" (let [result (qp/process-query (mt/mbql-query venues @@ -2152,7 +2076,6 @@ :fields [$id $name] :limit 3}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to use left join with restricted table" (is (thrown-with-msg? ExceptionInfo @@ -2181,7 +2104,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :query-builder) ;; Block access to users table (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-test-user :rasta (testing "Should be able to use multiple join strategies with all accessible tables" (let [result (qp/process-query @@ -2199,7 +2121,6 @@ :fields [$id $name] :limit 2}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to use multiple join strategies when one table is restricted" (is (thrown-with-msg? ExceptionInfo @@ -2234,7 +2155,6 @@ (perms/set-table-permission! (perms/all-users-group) (mt/id :checkins) :perms/create-queries :no) ;; Block access to users table completely (perms/set-table-permission! (perms/all-users-group) (mt/id :users) :perms/view-data :blocked) - (mt/with-temp [:model/Collection collection] (perms/grant-collection-read-permissions! (perms/all-users-group) collection) ;; Create a card with checkins data (allowed via collection permissions) @@ -2254,7 +2174,6 @@ :fields [$id $name] :limit 2}))] (is (seq (mt/rows result))))) - (testing "Should be able to join multiple cards and tables in complex chain" (let [result (qp/process-query (mt/mbql-query venues @@ -2269,7 +2188,6 @@ :fields [$id $name] :limit 2}))] (is (seq (mt/rows result))))) - (testing "Should NOT be able to join to blocked table even in complex chain" (is (thrown-with-msg? ExceptionInfo diff --git a/test/metabase/query_processor/middleware/process_userland_query_test.clj b/test/metabase/query_processor/middleware/process_userland_query_test.clj index ba3d0da7d724..f8f1fe2c2afe 100644 --- a/test/metabase/query_processor/middleware/process_userland_query_test.clj +++ b/test/metabase/query_processor/middleware/process_userland_query_test.clj @@ -142,7 +142,6 @@ (with-query-execution! [qe query] (process-userland-query query) (is (=? {:parameterized false} (qe))))) - (let [query (mt/query venues {:query {:aggregation [[:count]]} :parameters [{:name "price" @@ -152,7 +151,6 @@ (with-query-execution! [qe query] (process-userland-query query) (is (=? {:parameterized false} (qe))))) - (let [query (mt/query venues {:query {:aggregation [[:count]]} :parameters [{:name "price" diff --git a/test/metabase/query_processor/middleware/reconcile_breakout_and_order_by_bucketing_test.clj b/test/metabase/query_processor/middleware/reconcile_breakout_and_order_by_bucketing_test.clj index 5f833a91c764..76bb5473726e 100644 --- a/test/metabase/query_processor/middleware/reconcile_breakout_and_order_by_bucketing_test.clj +++ b/test/metabase/query_processor/middleware/reconcile_breakout_and_order_by_bucketing_test.clj @@ -85,7 +85,6 @@ (reconcile-breakout-and-order-by-bucketing :breakout [[:field 2 {:temporal-unit :day}]] :order-by [[:asc [:field 1 nil]]])))) - (testing (str "similarly, if a datetime field is already bucketed in a different way in the order-by than the same " "Field in a breakout clause, we should not do anything, even though the query is likely invalid " "(we assume you know what you're doing if you explicitly specify a bucketing)") diff --git a/test/metabase/query_processor/middleware/resolve_referenced_test.clj b/test/metabase/query_processor/middleware/resolve_referenced_test.clj index 8cf7c339cded..a4be7fef6fcc 100644 --- a/test/metabase/query_processor/middleware/resolve_referenced_test.clj +++ b/test/metabase/query_processor/middleware/resolve_referenced_test.clj @@ -175,7 +175,6 @@ :type :native :native {:query "SELECT * FROM {{#1}}" :template-tags (card-template-tags [1])}})] - (testing "Should throw an exception for circular reference" (is (thrown-with-msg? ExceptionInfo @@ -228,13 +227,11 @@ :type :native :native {:query "SELECT * FROM {{#1}}" :template-tags (card-template-tags [1])}})] - (testing "Should throw an exception for circular reference" (is (thrown-with-msg? ExceptionInfo #"circular|cycle" (#'qp.resolve-referenced/check-for-circular-references entrypoint-query)))) - (testing "The cycle detection should work from any entry point" ;; Starting from Card B should also detect the cycle (let [card-b-query (lib/query @@ -276,7 +273,6 @@ ExceptionInfo #"circular|cycle" (#'qp.resolve-referenced/check-for-circular-references entrypoint-query)))))) - (testing "Self-referencing snippet" (let [metadata-provider-with-snippet (lib.tu/mock-metadata-provider @@ -335,7 +331,6 @@ :type :native :native {:query "SELECT * FROM {{#1}}" :template-tags (card-template-tags [1])}})] - (testing "Should NOT throw an exception for valid non-cyclic chain" ;; This should complete, without throwing an exception, and return a dependency graph (is (some? (#'qp.resolve-referenced/check-for-circular-references entrypoint-query)))))))) diff --git a/test/metabase/query_processor/middleware/resolve_source_table_test.clj b/test/metabase/query_processor/middleware/resolve_source_table_test.clj index c3d716c37db7..f5416e9ccc71 100644 --- a/test/metabase/query_processor/middleware/resolve_source_table_test.clj +++ b/test/metabase/query_processor/middleware/resolve_source_table_test.clj @@ -66,7 +66,6 @@ (resolve-and-return-cached-metadata (lib.tu.macros/mbql-query nil {:source-query {:source-table $$venues}})))) - (is (= {:tables #{"VENUES"}} (resolve-and-return-cached-metadata (lib.tu.macros/mbql-query nil diff --git a/test/metabase/query_processor/middleware/results_metadata_test.clj b/test/metabase/query_processor/middleware/results_metadata_test.clj index a1e651fed4c9..fd22ddc4197c 100644 --- a/test/metabase/query_processor/middleware/results_metadata_test.clj +++ b/test/metabase/query_processor/middleware/results_metadata_test.clj @@ -331,7 +331,6 @@ :source :native :field_ref [:field "D" {:base-type :type/Date}]} (first (:cols results))))) - (testing "Results metadata should have the same type info" (is (=? {:base_type :type/Date :effective_type :type/Date diff --git a/test/metabase/query_processor/middleware/update_used_cards_test.clj b/test/metabase/query_processor/middleware/update_used_cards_test.clj index fd9e1b4c78c4..5d31315387e9 100644 --- a/test/metabase/query_processor/middleware/update_used_cards_test.clj +++ b/test/metabase/query_processor/middleware/update_used_cards_test.clj @@ -104,7 +104,6 @@ (-> (t2/select-one-fn :last_used_at :model/Card card-id-1) t/offset-date-time (.withNano 0)))))) - (testing "if the existing last_used_at is greater than the updating values, do not override it" (mt/with-temp [:model/Card {card-id-2 :id} {:last_used_at now}] diff --git a/test/metabase/query_processor/nested_field_test.clj b/test/metabase/query_processor/nested_field_test.clj index 03f39c8942f8..a22b11df8121 100644 --- a/test/metabase/query_processor/nested_field_test.clj +++ b/test/metabase/query_processor/nested_field_test.clj @@ -78,7 +78,6 @@ (mt/first-row (mt/run-mbql-query tips {:aggregation [[:distinct $tips.venue.name]]}))))) - (testing ":count aggregation" ;; Now let's just get the regular count (is (= [500] diff --git a/test/metabase/query_processor/nested_queries_test.clj b/test/metabase/query_processor/nested_queries_test.clj index 9a983645489e..ff0e81d04dd0 100644 --- a/test/metabase/query_processor/nested_queries_test.clj +++ b/test/metabase/query_processor/nested_queries_test.clj @@ -783,7 +783,6 @@ (is (= (mi/perms-objects-set collection :read) (mi/perms-objects-set card-1 :read) (mi/perms-objects-set card-2 :read))) - (testing "\nSanity check: shouldn't be able to read before we grant permissions\n" (doseq [[object-name object] {"Collection" collection "Card 1" card-1 @@ -792,7 +791,6 @@ (testing object-name (is (= false (mi/can-read? object))))))) - (testing "\nshould be able to read nested-nested Card if we have Collection permissions\n" (perms/grant-collection-read-permissions! (perms/all-users-group) collection) (mt/with-test-user :rasta @@ -802,7 +800,6 @@ (testing object-name (is (true? (mi/can-read? object))))) - (testing "\nshould be able to run the query" (is (= [[1 "Red Medicine" 4 10.0646 -165.374 3] [2 "Stout Burgers & Beers" 11 34.0996 -118.329 2]] @@ -843,7 +840,6 @@ (perms/grant-collection-read-permissions! (perms/all-users-group) source-card-collection) (perms/grant-collection-readwrite-permissions! (perms/all-users-group) dest-card-collection) (is (some? (save-card-via-API-with-native-source-query! 200 (mt/db) source-card-collection dest-card-collection))))) - (testing (str "however, if we do *not* have read permissions for the source Card's collection we shouldn't be " "allowed to save the query. This API call should fail") (testing "Card in the Root Collection" @@ -853,7 +849,6 @@ (perms/grant-collection-readwrite-permissions! (perms/all-users-group) dest-card-collection) (is (=? {:message "You cannot save this Question because you do not have permissions to run its query."} (save-card-via-API-with-native-source-query! 403 (mt/db) nil dest-card-collection))))) - (testing "Card in a different Collection for which we do not have perms" ;; allowing `with-temp` here since we need it to make Collections #_{:clj-kondo/ignore [:discouraged-var]} @@ -862,7 +857,6 @@ (perms/grant-collection-readwrite-permissions! (perms/all-users-group) dest-card-collection) (is (=? {:message "You cannot save this Question because you do not have permissions to run its query."} (save-card-via-API-with-native-source-query! 403 (mt/db) source-card-collection dest-card-collection))))) - (testing "similarly, if we don't have *write* perms for the dest collection it should also fail" (testing "Try to save in the Root Collection" ;; allowing `with-temp` here since we need it to make Collections @@ -871,7 +865,6 @@ (perms/grant-collection-read-permissions! (perms/all-users-group) source-card-collection) (is (= "You don't have permissions to do that." (save-card-via-API-with-native-source-query! 403 (mt/db) source-card-collection nil))))) - (testing "Try to save in a different Collection for which we do not have perms" ;; allowing `with-temp` here since we need it to make Collections #_{:clj-kondo/ignore [:discouraged-var]} @@ -1696,7 +1689,6 @@ (lib.metadata/field mp (mt/id "space table" "space column")) (lib/with-join-alias (lib.metadata/field mp (mt/id "space table" "space column")) "Space Table Alias"))]))) - (lib/breakout $q (m/find-first (every-pred (comp #{"Space Column"} :display-name) :lib/original-join-alias) (lib/breakoutable-columns $q))) (lib/append-stage $q) diff --git a/test/metabase/query_processor/pivot/postprocess_test.clj b/test/metabase/query_processor/pivot/postprocess_test.clj index b33fead598d2..226d55a14db1 100644 --- a/test/metabase/query_processor/pivot/postprocess_test.clj +++ b/test/metabase/query_processor/pivot/postprocess_test.clj @@ -13,7 +13,6 @@ result (#'pivot.postprocess/build-top-headers top-header-items left-header-items row-indexes display-name-for-col)] (is (= [["Row" "A" "A" "B"]] result)))) - (testing "builds top headers with multi-level hierarchy" (let [top-header-items [{:depth 0 :value "A" :span 2} {:depth 1 :value "X" :span 1} @@ -28,7 +27,6 @@ (is (= [[nil nil "A" "A" "B"] ["Row1" "Row2" "X" "Y" "Z"]] result)))) - (testing "handles the case where the max depth of the left-header-items tree is less than the count of row-indexes (#58340)" (let [top-header-items [{:depth 0 :value "A" :span 2} {:depth 1 :value "X" :span 1} @@ -44,7 +42,6 @@ (is (= [[nil "A" "A" "B"] ["Row1" "X" "Y" "Z"]] result)))) - (testing "handles empty top header items without error" (let [top-header-items [] left-header-items [{:depth 0 :value "Row1" :span 1 :offset 0 :maxDepthBelow 0}] @@ -62,7 +59,6 @@ (is (= [["A"] ["B"]] result)))) - (testing "builds left headers with multi-level hierarchy" (let [left-header-items [{:depth 0 :value "A" :span 2 :offset 0} {:depth 1 :value "X" :span 1 :offset 0} @@ -74,7 +70,6 @@ ["A" "Y"] ["B" "Z"]] result)))) - (testing "handles empty left header items without error" (let [left-header-items [] result (#'pivot.postprocess/build-left-headers left-header-items)] @@ -99,7 +94,6 @@ ["Row A" "100" "300"] ["Row B" "200" "400"]] result)))) - (testing "handles multiple measures per column" (let [get-row-section (fn [col-idx row-idx] (case [col-idx row-idx] @@ -117,7 +111,6 @@ ["Row A" "100" "101" "300" "301"] ["Row B" "200" "201" "400" "401"]] result)))) - (testing "handles empty left headers without error" (let [get-row-section (fn [col-idx row-idx] (case [col-idx row-idx] @@ -130,7 +123,6 @@ (is (= [["" "Col X"] ["100"]] result)))) - (testing "handles no values in row sections without error" (let [get-row-section (constantly []) left-headers [["Row A"]] @@ -140,7 +132,6 @@ (is (= [["" "Col X"] ["Row A"]] result)))) - (testing "handles zero measure-count with no error" (let [get-row-section (constantly []) left-headers [] diff --git a/test/metabase/query_processor/pivot_test.clj b/test/metabase/query_processor/pivot_test.clj index 0aee5b5416bc..db3bfe2d84d3 100644 --- a/test/metabase/query_processor/pivot_test.clj +++ b/test/metabase/query_processor/pivot_test.clj @@ -110,9 +110,9 @@ clojure.lang.ExceptionInfo #"Invalid pivot-cols: specified breakout at index 3, but we only have 3 breakouts" (#'qp.pivot/breakout-combinations 3 [] [0 1 2 3] true true))))) - ;; TODO -- we should require these columns to be distinct as well (I think?) - ;; TODO -- require all numbers to be positive - ;; TODO -- can you specify something in both pivot-rows and pivot-cols? +;; TODO -- we should require these columns to be distinct as well (I think?) +;; TODO -- require all numbers to be positive +;; TODO -- can you specify something in both pivot-rows and pivot-cols? (defn- test-query [] (mt/dataset test-data diff --git a/test/metabase/query_processor/remapping_test.clj b/test/metabase/query_processor/remapping_test.clj index 8d1f9fff776d..3de926f36679 100644 --- a/test/metabase/query_processor/remapping_test.clj +++ b/test/metabase/query_processor/remapping_test.clj @@ -262,7 +262,7 @@ (is (= [[1 1 14 37.65 2.07 39.72 nil "2019-02-11T21:40:27.892Z" 2 "Awesome Concrete Shoes"] [2 1 123 110.93 6.1 117.03 nil "2018-05-15T08:04:04.58Z" 3 "Mediocre Wooden Bench"]] (remappings-with-metadata metadata)))))))) - ;; doesn't currently work with any other metadata. +;; doesn't currently work with any other metadata. (deftest remappings-with-implicit-joins-test (mt/with-temporary-setting-values [report-timezone "UTC"] diff --git a/test/metabase/query_processor/split_part_test.clj b/test/metabase/query_processor/split_part_test.clj index ac94e30e1af1..4cb674287c9d 100644 --- a/test/metabase/query_processor/split_part_test.clj +++ b/test/metabase/query_processor/split_part_test.clj @@ -72,20 +72,15 @@ examples [{:text "ABC-123" :delimiter "-" :position 1 :expected "ABC" :msg "Easy case."} {:text "ABC-123" :delimiter "-" :position 2 :expected "123" :msg "Easy case."} {:text "ABC-123" :delimiter "-" :position 3 :expected "" :msg "Position too high."} - {:text "John Doe" :delimiter " " :position 1 :expected "John" :msg "Single space delimiter."} - {:text "/ABC/123/" :delimiter "/" :position 1 :expected "" :msg "Empty part when delimiter is first char."} {:text "/ABC/123/" :delimiter "/" :position 2 :expected "ABC" :msg "First part of path."} {:text "/ABC/123/" :delimiter "/" :position 3 :expected "123" :msg "Second part of path."} {:text "/ABC/123/" :delimiter "/" :position 4 :expected "" :msg "Empty part when delimiter is last char."} {:text "/ABC/123/" :delimiter "/" :position 9 :expected "" :msg "Empty part when position out of bounds."} - {:text "ABC-123" :delimiter "," :position 1 :expected "ABC-123" :msg "Delimiter doesn't exist."} - {:text "ABC-123" :delimiter "ABC-123" :position 1 :expected "" :msg "Delimiter matches whole string."} {:text "ABC-123" :delimiter "ABC-123" :position 2 :expected "" :msg "Delimiter matches whole string."} - {:text "ABC-123" :delimiter "ABC-1235" :position 1 :expected "ABC-123" :msg "Delimiter longer than whole string."}]] (doseq [{:keys [text delimiter position expected msg]} examples] (testing (str "split part: " msg) @@ -127,7 +122,6 @@ examples [{:text "" :delimiter "" :position 1 :msg "Empty delimiter"} {:text "" :delimiter (lib/concat "" "j") :position 1 :msg "expression delimiter"} {:text "" :delimiter (lib.metadata/field mp (mt/id :people :id)) :position 1 :msg "field delimiter"} - {:text "John Doe" :delimiter " " :position 0 :msg "Zero position."} {:text "John Doe" :delimiter " " :position -1 :msg "Negative position."}]] (doseq [{:keys [text delimiter position msg]} examples] diff --git a/test/metabase/query_processor/streaming/common_test.clj b/test/metabase/query_processor/streaming/common_test.clj index 2050dede8339..b87f3e23692b 100644 --- a/test/metabase/query_processor/streaming/common_test.clj +++ b/test/metabase/query_processor/streaming/common_test.clj @@ -47,7 +47,6 @@ {::mb.viz/column-title "test 7"}}} format-rows? true titles (streaming.common/column-titles ordered-cols viz-settings format-rows?)] - (testing "both settings (title and time) should be applied to the same column" (is (= ["test 7"] titles)))))) @@ -55,7 +54,6 @@ (testing "column-title setting precedence when the same column has multiple settings" (let [ordered-cols [{:name "AMOUNT" :id 42 :display_name "Amount"}] format-rows? true] - (testing "column-name settings override field-id settings" (let [viz-settings {::mb.viz/column-settings {;; Field ID column setting diff --git a/test/metabase/query_processor/streaming/csv_test.clj b/test/metabase/query_processor/streaming/csv_test.clj index bed0c5f30e2b..cb6c401101b4 100644 --- a/test/metabase/query_processor/streaming/csv_test.clj +++ b/test/metabase/query_processor/streaming/csv_test.clj @@ -134,7 +134,6 @@ (testing "Lazy seqs within rows are automatically realized during exports (#26261)" (let [row (first (csv-export [[(lazy-seq [1 2 3])]]))] (is (= ["[1 2 3]"] row)))) - (testing "LocalDate in a lazy seq (checking that elements in a lazy seq are formatted correctly as strings)" (let [row (first (csv-export [[(lazy-seq [#t "2021-03-30T"])]]))] (is (= ["[\"2021-03-30\"]"] row))))) diff --git a/test/metabase/query_processor/streaming/xlsx_test.clj b/test/metabase/query_processor/streaming/xlsx_test.clj index f53ae8740142..8673f99b78ec 100644 --- a/test/metabase/query_processor/streaming/xlsx_test.clj +++ b/test/metabase/query_processor/streaming/xlsx_test.clj @@ -464,7 +464,6 @@ {} [[1] [1.23] [1.004] [1.005] [10000000000] [10000000000.123]] :parse-fn parse-format-strings))))) - (testing "Misc format strings are included correctly in exports" (is (= ["[$€]#,##0.00"] (second (xlsx-export [{:field_ref [:field 0] :name "Col" :semantic_type :type/Cost}] @@ -489,13 +488,11 @@ (first (xlsx-export [{:id 0, :name "Col1"} {:id 1, :name "Col2"}] {} [])))) (is (= ["Col2" "Col1"] (first (xlsx-export [{:id 0, :name "Col2"} {:id 1, :name "Col1"}] {} []))))) - (testing "Data in each row is reordered by output-order prior to export" (is (= [["b" "a"] ["d" "c"]] (rest (xlsx-export [{:id 0, :name "Col1"} {:id 1, :name "Col2"}] {:output-order [1 0]} [["a" "b"] ["c" "d"]]))))) - (testing "Rows not included by index in output-order are excluded from export" (is (= [["b"] ["d"]] (rest (xlsx-export [{:id 0, :name "Col1"} {:id 1, :name "Col2"}] @@ -515,7 +512,6 @@ (first (xlsx-export [{:display_name "Display name", :name "Name"}] {::mb.viz/column-settings {{::mb.viz/column-name "Name"} {::mb.viz/column-title "Column title"}}} []))))) - (testing "Currency is included in column title if necessary" ;; Dollar symbol is included by default if semantic type of column derives from :type/Currency (is (= ["Col ($)"] @@ -573,7 +569,6 @@ ::mb.viz/currency-style "code", ::mb.viz/currency-in-header false}}} []))))) - (testing "If a col is remapped to a foreign key field, the title is taken from the viz settings for its fk_field_id (#18573)" (is (= ["Correct title"] (first (xlsx-export [{:id 0, :fk_field_id 1, :remapped_from "FIELD_1" :field_ref [:field 0]}] diff --git a/test/metabase/query_processor/streaming_test.clj b/test/metabase/query_processor/streaming_test.clj index bfbc49cd4f1d..4aef00bfa8cc 100644 --- a/test/metabase/query_processor/streaming_test.clj +++ b/test/metabase/query_processor/streaming_test.clj @@ -403,7 +403,7 @@ :limit 2}} :assertions {:csv (fn [results] (is (string? results)) - ;; CSVs round decimals to 2 digits without viz-settings + ;; CSVs round decimals to 2 digits without viz-settings (is (= [["ID" "Name" "Category ID" "Latitude" "Longitude" "Price"] ["1" "Red Medicine" "4" "10.06460000° N" "165.37400000° W" "3"] ["2" "Stout Burgers & Beers" "11" "34.09960000° N" "118.32900000° W" "2"]] @@ -755,7 +755,6 @@ _ (a/>!! canceled-chan ::cancel) query (mt/mbql-query venues {:limit 1}) mock-rff (constantly identity)] - (binding [qp.pipeline/*canceled-chan* canceled-chan] (let [result (qp.pipeline/*run* query mock-rff)] (is (nil? result) "Cancelled query returns nil") @@ -764,10 +763,9 @@ (deftest streaming-response-handles-cancellation-test (testing "Streaming response handles cancellation gracefully without assertion errors" (let [mock-qp-fn (fn [rff] - ;; Simulate immediate cancellation + ;; Simulate immediate cancellation (with-redefs [qp.pipeline/canceled? (constantly true)] (qp.pipeline/*run* (mt/mbql-query venues {:limit 1}) rff)))] - ;; Should not throw "QP unexpectedly returned nil" assertion error (is (some? (qp.streaming/-streaming-response :csv "test" mock-qp-fn)) "Streaming response should handle cancellation without assertion error")))) @@ -778,7 +776,6 @@ ;; Return nil and set up canceled? to return truthy (with-redefs [qp.pipeline/canceled? (constantly ::cancel)] nil))] - ;; Should not throw any assertion errors (is (some? (qp.streaming/-streaming-response :csv "test" mock-qp-fn)) "Streaming response should handle cancellation without assertion error")))) diff --git a/test/metabase/query_processor/sum_where_test.clj b/test/metabase/query_processor/sum_where_test.clj index b9b787775f34..6b5bd8f28e26 100644 --- a/test/metabase/query_processor/sum_where_test.clj +++ b/test/metabase/query_processor/sum_where_test.clj @@ -17,7 +17,6 @@ mt/rows ffirst double))) - (testing "Should get normalized correctly and work as expected" (is (= 179.0 (->> {:aggregation [["sum-where" diff --git a/test/metabase/query_processor/test_util.clj b/test/metabase/query_processor/test_util.clj index 7bb94ab14799..a8f2263ac62e 100644 --- a/test/metabase/query_processor/test_util.clj +++ b/test/metabase/query_processor/test_util.clj @@ -292,7 +292,6 @@ (when (= (:status response) :failed) (log/warnf "Error running query: %s" (u/pprint-to-str 'red response)) (throw (ex-info (:error response) response))) - (let [format-fns (map format-rows-fn (format-rows-fns format-fns))] (-> response ((fn format-rows [rows] diff --git a/test/metabase/query_processor/timeseries_test.clj b/test/metabase/query_processor/timeseries_test.clj index 68ed6609cdc5..3cc20f2e72a9 100644 --- a/test/metabase/query_processor/timeseries_test.clj +++ b/test/metabase/query_processor/timeseries_test.clj @@ -135,7 +135,6 @@ $user_last_login] :order-by [[direction $timestamp]] :limit 2}))))))) - (testing "for a query with :fields" (doseq [[direction expected-rows] {:desc [["Señor Fish" "Mexican" "2015-12-29T00:00:00Z"] ["Empress of China" "Chinese" "2015-12-26T00:00:00Z"]] @@ -156,7 +155,6 @@ (mt/first-row (run-mbql-query checkins {:aggregation [[:count]]})))) - (testing "count of field" (is (= [1000] (mt/first-row @@ -219,7 +217,6 @@ (mt/rows+column-names (run-mbql-query checkins {:breakout [$user_name]}))))) - (testing "2 breakouts" (is (= {:columns ["user_name" "venue_category_name"] :rows [["Broen Olujimi" "American"] @@ -256,7 +253,6 @@ {:breakout [$user_name] :order-by [[:desc $user_name]] :limit 10}))))) - (testing "2 breakouts w/ explicit order by" (is (= {:columns ["user_name" "venue_category_name"] :rows [["Broen Olujimi" "American"] @@ -298,7 +294,6 @@ (run-mbql-query checkins {:aggregation [[:count]] :breakout [$user_name]}))))) - (testing "count w/ 2 breakouts" (is (= {:columns ["user_name" "venue_category_name" "count"] :rows [["Broen Olujimi" "American" 8] @@ -325,21 +320,18 @@ (run-mbql-query checkins {:aggregation [[:count]] :filter [:> $venue_price 3]}))))) - (testing "filter <" (is (= [836] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:< $venue_price 3]}))))) - (testing "filter >=" (is (= [164] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:>= $venue_price 3]}))))) - (testing "filter <=" (is (= [951] (mt/first-row @@ -361,7 +353,6 @@ {:fields [$user_name $venue_name $venue_category_name $timestamp] :filter [:= $user_name "Plato Yeshua"] :limit 5}))))) - (testing "filter !=" (is (= [969] (mt/first-row @@ -380,7 +371,6 @@ :filter [:and [:= $venue_category_name "Bar"] [:= $user_name "Plato Yeshua"]]}))))) - (testing "filter OR" (is (= [199] (mt/first-row @@ -433,14 +423,12 @@ (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:starts-with $venue_category_name "Me"]})))) - (is (= {:columns ["venue_category_name"] :rows []} (mt/rows+column-names (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:starts-with $venue_category_name "ME"]})))) - (testing "case insensitive" (is (= {:columns ["venue_category_name"] :rows [["Mediterannian"] ["Mexican"]]} @@ -464,14 +452,12 @@ (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:ends-with $venue_category_name "an"]})))) - (is (= {:columns ["venue_category_name"] :rows []} (mt/rows+column-names (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:ends-with $venue_category_name "AN"]})))) - (testing "case insensitive" (is (= {:columns ["venue_category_name"] :rows [["American"] @@ -502,14 +488,12 @@ (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:contains $venue_category_name "er"]})))) - (is (= {:columns ["venue_category_name"] :rows []} (mt/rows+column-names (run-mbql-query checkins {:breakout [$venue_category_name] :filter [:contains $venue_category_name "eR"]})))) - (testing "case insensitive" (is (= {:columns ["venue_category_name"] :rows [["American"] @@ -594,7 +578,6 @@ [[:minute-of-hour [[0 1000]] [int int]] - [:hour [["2013-01-03T00:00:00Z" 1] ["2013-01-10T00:00:00Z" 1] @@ -602,11 +585,9 @@ ["2013-01-22T00:00:00Z" 1] ["2013-01-23T00:00:00Z" 1]] [iso8601 int]] - [:hour-of-day [[0 1000]] [int int]] - [:week [["2012-12-30" 1] ["2013-01-06" 1] @@ -614,7 +595,6 @@ ["2013-01-20" 4] ["2013-01-27" 1]] [iso8601-date-part int]] - [:day [["2013-01-03T00:00:00Z" 1] ["2013-01-10T00:00:00Z" 1] @@ -622,7 +602,6 @@ ["2013-01-22T00:00:00Z" 1] ["2013-01-23T00:00:00Z" 1]] [iso8601 int]] - [:day-of-week [[1 135] [2 143] @@ -630,7 +609,6 @@ [4 136] [5 139]] [int int]] - [:day-of-month [[1 36] [2 36] @@ -638,7 +616,6 @@ [4 35] [5 43]] [int int]] - [:day-of-year [[3 2] [4 6] @@ -646,7 +623,6 @@ [6 1] [7 2]] [int int]] - [:week-of-year [[1 8] [2 7] @@ -654,7 +630,6 @@ [4 8] [5 14]] [int int]] - [:month [["2013-01-01" 8] ["2013-02-01" 11] @@ -662,7 +637,6 @@ ["2013-04-01" 26] ["2013-05-01" 23]] [iso8601-date-part int]] - [:month-of-year [[1 38] [2 70] @@ -670,7 +644,6 @@ [4 89] [5 111]] [int int]] - [:quarter [["2013-01-01" 40] ["2013-04-01" 75] @@ -678,20 +651,18 @@ ["2013-10-01" 65] ["2014-01-01" 107]] [iso8601-date-part int]] - [:quarter-of-year [[1 200] [2 284] [3 278] [4 238]] [int int]] - [:year [["2013-01-01" 235] ["2014-01-01" 498] ["2015-01-01" 267]] [iso8601-date-part int]]] - ;; TODO: Find a way how to make those work with Druid JDBC. + ;; TODO: Find a way how to make those work with Druid JDBC. :when (not (#{:week-of-year :day-of-week :week} unit))] (testing unit (testing "topN query" @@ -705,8 +676,8 @@ columns)) (is (= expected-rows rows)))) - ;; This test is similar to the above query but doesn't use a limit clause which causes the query to be a - ;; grouped timeseries query rather than a topN query. The dates below are formatted incorrectly due to + ;; This test is similar to the above query but doesn't use a limit clause which causes the query to be a + ;; grouped timeseries query rather than a topN query. The dates below are formatted incorrectly due to (testing "group timeseries query" (let [{:keys [columns rows]} (mt/formatted-rows+column-names format-fns @@ -727,7 +698,6 @@ (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:= $id 1]]}))))) - (testing :!= (is (= [1] (mt/first-row @@ -740,70 +710,60 @@ (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:< $id 40]]}))))) - (testing :> (is (= [40] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:> $id 40]]}))))) - (testing :<= (is (= [960] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:<= $id 40]]}))))) - (testing :>= (is (= [39] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:>= $id 40]]}))))) - (testing :is-null (is (= [1000] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:is-null $id]]}))))) - (testing :between (is (= [989] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:between $id 30 40]]}))))) - (testing :inside (is (= [377] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:inside $venue_latitude $venue_longitude 40 -120 30 -110]]}))))) - (testing :starts-with (is (= [795] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:starts-with $venue_name "T"]]}))))) - (testing :contains (is (= [971] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:contains $venue_name "BBQ"]]}))))) - (testing :ends-with (is (= [884] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:ends-with $venue_name "a"]]}))))) - (testing :and (is (= [975] (mt/first-row @@ -812,7 +772,6 @@ :filter [:not [:and [:> $id 32] [:contains $venue_name "BBQ"]]]}))))) - (testing :or (is (= [28] (mt/first-row @@ -820,7 +779,6 @@ {:aggregation [[:count]] :filter [:not [:or [:> $id 32] [:contains $venue_name "BBQ"]]]}))))) - (testing "nested and/or" (is (= [969] (mt/first-row @@ -830,14 +788,12 @@ [:> $id 32] [:< $id 35]] [:contains $venue_name "BBQ"]]]}))))) - (testing "nested :not" (is (= [29] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] :filter [:not [:not [:contains $venue_name "BBQ"]]]}))))) - (testing ":not nested inside and/or" (is (= [4] (mt/first-row @@ -846,13 +802,12 @@ :filter [:and [:not [:> $id 32]] [:contains $venue_name "BBQ"]]}))))) - (testing :time-interval (is (= [1000] (mt/first-row (run-mbql-query checkins {:aggregation [[:count]] - ;; test data is all in the past so nothing happened today <3 + ;; test data is all in the past so nothing happened today <3 :filter [:not [:time-interval $timestamp :current :day]]})))))))) (deftest ^:parallel min-test @@ -863,14 +818,12 @@ [double] (run-mbql-query checkins {:aggregation [[:min $venue_price]]}))))) - (testing "metric columns" (is (= [[1.0]] (mt/formatted-rows [double] (run-mbql-query checkins {:aggregation [[:min $count]]}))))) - (testing "with breakout" ;; some sort of weird quirk w/ druid where all columns in breakout get converted to strings (is (= [["1" 34.0071] ["2" 33.7701] ["3" 10.0646] ["4" 33.983]] @@ -889,14 +842,12 @@ [double] (run-mbql-query checkins {:aggregation [[:max $venue_price]]}))))) - (testing "metric columns" (is (= [[1.0]] (mt/formatted-rows [double] (run-mbql-query checkins {:aggregation [[:max $count]]}))))) - (testing "with breakout" (is (= [["1" 37.8078] ["2" 40.7794] ["3" 40.7262] ["4" 40.7677]] (mt/formatted-rows diff --git a/test/metabase/remote_sync/init_test.clj b/test/metabase/remote_sync/init_test.clj index 10c9011eab5a..f2cae97e0cc0 100644 --- a/test/metabase/remote_sync/init_test.clj +++ b/test/metabase/remote_sync/init_test.clj @@ -24,10 +24,8 @@ (let [hydrated-cards (t2/hydrate [card-in-remote card-in-normal card-no-coll] :is_remote_synced)] (testing "card in remote-synced collection has is_remote_synced = true" (is (true? (:is_remote_synced (first hydrated-cards))))) - (testing "card in normal collection has is_remote_synced = false" (is (false? (:is_remote_synced (second hydrated-cards))))) - (testing "card without collection has is_remote_synced = false" (is (false? (:is_remote_synced (nth hydrated-cards 2))))))))) @@ -45,7 +43,6 @@ (let [hydrated-dashboards (t2/hydrate [dash-in-remote dash-in-normal] :is_remote_synced)] (testing "dashboard in remote-synced collection has is_remote_synced = true" (is (true? (:is_remote_synced (first hydrated-dashboards))))) - (testing "dashboard in normal collection has is_remote_synced = false" (is (false? (:is_remote_synced (second hydrated-dashboards))))))))) @@ -65,11 +62,9 @@ (testing "multiple cards in remote-synced collection are correctly hydrated" (is (true? (:is_remote_synced (first hydrated-cards)))) (is (true? (:is_remote_synced (second hydrated-cards))))) - (testing "multiple cards in normal collection are correctly hydrated" (is (false? (:is_remote_synced (nth hydrated-cards 2)))) (is (false? (:is_remote_synced (nth hydrated-cards 3))))) - (testing "card without collection is correctly hydrated" (is (false? (:is_remote_synced (nth hydrated-cards 4))))))))) diff --git a/test/metabase/request/session_test.clj b/test/metabase/request/session_test.clj index 4cdb7736a0f7..b03d60b99d0d 100644 --- a/test/metabase/request/session_test.clj +++ b/test/metabase/request/session_test.clj @@ -28,9 +28,9 @@ (testing "as-admin overrides *is-superuser?* and *current-user-permissions-set*" (request/with-current-user (mt/user->id :rasta) (request/as-admin - ;; Current user ID remains the same + ;; Current user ID remains the same (is (= (mt/user->id :rasta) *current-user-id*)) - ;; *is-superuser?* and permissions set are overrided + ;; *is-superuser?* and permissions set are overrided (is (true? api/*is-superuser?*)) (is (= #{"/"} @api/*current-user-permissions-set*))))) (testing "as-admin preserves any locale settings" diff --git a/test/metabase/request/util_test.clj b/test/metabase/request/util_test.clj index f019d97b9640..6f0ded12743d 100644 --- a/test/metabase/request/util_test.clj +++ b/test/metabase/request/util_test.clj @@ -98,7 +98,6 @@ (testing "request with no forwarding" (is (= "127.0.0.1" (request.current/ip-address request)))) - (testing "request with forwarding" (let [mock-request (-> (ring.mock/request :get "api/session") (ring.mock/header "X-Forwarded-For" "5.6.7.8"))] @@ -115,7 +114,6 @@ (ring.mock/header "x-proxyuser-ip" "1.2.3.4"))] (is (= "1.2.3.4" (request.current/ip-address mock-request))))))) - (testing "forwarding explicitly disabled via MB_NOT_BEHIND_PROXY=true" (mt/with-temp-env-var-value! [mb-not-behind-proxy "true"] (let [mock-request (-> (ring.mock/request :get "api/session") diff --git a/test/metabase/revisions/api_test.clj b/test/metabase/revisions/api_test.clj index bc38b097a5c6..89c2fe1f61b4 100644 --- a/test/metabase/revisions/api_test.clj +++ b/test/metabase/revisions/api_test.clj @@ -71,7 +71,6 @@ (doseq [i (range (inc revision/max-revisions))] (t2/update! :model/Card (:id card) {:name (format "New name %d" i)}) (create-card-revision! (:id card) false :rasta)) - (is (= ["renamed this Card from \"New name 14\" to \"New name 15\"." "renamed this Card from \"New name 13\" to \"New name 14\"." "renamed this Card from \"New name 12\" to \"New name 13\"." @@ -244,15 +243,12 @@ :model/Dashboard {dashboard-id :id} {:name "A dashboard"}] ;; 0. create the dashboard (create-dashboard-revision! dashboard-id true :crowberto) - ;; 1. rename (t2/update! :model/Dashboard :id dashboard-id {:name "New name"}) (create-dashboard-revision! dashboard-id false :crowberto) - ;; 2. add description (t2/update! :model/Dashboard :id dashboard-id {:description "A beautiful dashboard"}) (create-dashboard-revision! dashboard-id false :crowberto) - ;; 3. add 2 cards (let [dashcard-ids (t2/insert-returning-pks! :model/DashboardCard [{:dashboard_id dashboard-id :card_id card-id-1 @@ -267,24 +263,19 @@ :col 1 :row 1}])] (create-dashboard-revision! dashboard-id false :crowberto) - ;; 4. remove 1 card (t2/delete! :model/DashboardCard :id (first dashcard-ids)) (create-dashboard-revision! dashboard-id false :crowberto) - ;; 5. arrange cards (t2/update! :model/DashboardCard :id (second dashcard-ids) {:col 2 :row 2}) (create-dashboard-revision! dashboard-id false :crowberto)) - ;; 6. Move to a new collection (t2/update! :model/Dashboard :id dashboard-id {:collection_id coll-id}) (create-dashboard-revision! dashboard-id false :crowberto) - ;; 7. revert to an earlier revision (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Dashboard" :model_id dashboard-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Dashboard :id dashboard-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) - (is (= [{:description "reverted to an earlier version." :has_multiple_changes false} {:description "moved this Dashboard to New Collection.", @@ -336,32 +327,25 @@ :visualization_settings {}}] ;; 0. create the card (create-card-revision! card-id true :crowberto) - ;; 1. rename (t2/update! :model/Card :id card-id {:name "New name"}) (create-card-revision! card-id false :crowberto) - ;; 2. turn to a model (t2/update! :model/Card :id card-id {:type :model}) (create-card-revision! card-id false :crowberto) - ;; 3. edit query and metadata (t2/update! :model/Card :id card-id {:dataset_query (mt/mbql-query venues {:aggregation [[:count]]}) :display "scalar"}) (create-card-revision! card-id false :crowberto) - ;; 4. add description (t2/update! :model/Card :id card-id {:description "meaningful number"}) (create-card-revision! card-id false :crowberto) - -;; 5. change collection + ;; 5. change collection (t2/update! :model/Card :id card-id {:collection_id coll-id}) (create-card-revision! card-id false :crowberto) - ;; 6. revert to an earlier revision (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Card" :model_id card-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Card :id card-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) - (is (= [{:description "reverted to an earlier version.", :has_multiple_changes false} {:description "moved this Card to New Collection.", @@ -390,28 +374,22 @@ :visualization_settings {}}] ;; 0. create the card (create-card-revision! card-id true :crowberto) - ;; 1. rename (t2/update! :model/Card :id card-id {:name "New name"}) (create-card-revision! card-id false :crowberto) - ;; 2. edit query and metadata (t2/update! :model/Card :id card-id {:dataset_query (mt/mbql-query venues {:aggregation [[:count]]}) :display "scalar"}) (create-card-revision! card-id false :crowberto) - ;; 3. add description (t2/update! :model/Card :id card-id {:description "meaningful number"}) (create-card-revision! card-id false :crowberto) - -;; 4. change collection + ;; 4. change collection (t2/update! :model/Card :id card-id {:collection_id coll-id}) (create-card-revision! card-id false :crowberto) - ;; 5. revert to an earlier revision (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Card" :model_id card-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Card :id card-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) - (is (= [{:description "reverted to an earlier version.", :has_multiple_changes false} {:description "moved this Card to New Collection.", @@ -445,16 +423,13 @@ :visualization_settings {}}] ;; 0. create the card (create-card-revision! card-id true :crowberto) - ;; 1. rename (t2/update! :model/Card :id card-id {:description "meaningful number" :name "New name"}) (create-card-revision! card-id false :crowberto) - -;; 2. revert to an earlier revision + ;; 2. revert to an earlier revision (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Card" :model_id card-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Card :id card-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) - (is (= [{:description "est revenu à une version antérieure." :has_multiple_changes false} {:description "renommé ce Carte de A card à New name et ajouté une description." @@ -471,7 +446,6 @@ [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] ;; 0. create the dashboard (create-dashboard-revision! dashboard-id true :crowberto) - ;; 1. add 2 cards (t2/insert-returning-pks! :model/DashboardCard [{:dashboard_id dashboard-id :size_x 4 @@ -484,10 +458,8 @@ :col 1 :row 1}]) (create-dashboard-revision! dashboard-id false :crowberto) - (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Dashboard" :model_id dashboard-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Dashboard :id dashboard-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) - (is (= [{:description "reverted to an earlier version." :has_multiple_changes false} {:description "added 2 cards." @@ -515,7 +487,6 @@ (let [earlier-revision-id (t2/select-one-pk :model/Revision :model "Card" :model_id card-id {:order-by [[:timestamp :desc]]})] (revision/revert! {:entity :model/Card :id card-id :user-id (mt/user->id :crowberto) :revision-id earlier-revision-id})) (is (= "A card" (t2/select-one-fn :name :model/Card :id card-id)))) - (testing "Reverting a dashboard..." ;; Create the revision with an extra, unknown field on the dashboard (revision/push-revision! diff --git a/test/metabase/revisions/events_test.clj b/test/metabase/revisions/events_test.clj index ab98e4cab6f6..ba00c35df4d7 100644 --- a/test/metabase/revisions/events_test.clj +++ b/test/metabase/revisions/events_test.clj @@ -132,9 +132,8 @@ (mt/with-test-user :rasta (mt/with-temp [:model/Dashboard {dashboard-id :id, :as dashboard}] (events/publish-event! :event/dashboard-update {:object dashboard :user-id (mt/user->id :rasta)}) - - ;; we don't want the public_uuid and made_public_by_id to be recorded in a revision - ;; otherwise revert a card to earlier revision might toggle the public sharing settings + ;; we don't want the public_uuid and made_public_by_id to be recorded in a revision + ;; otherwise revert a card to earlier revision might toggle the public sharing settings (is (empty? (set/intersection #{:public_uuid :made_public_by_id} (->> (t2/select-one-fn :object :model/Revision :model "Dashboard" diff --git a/test/metabase/revisions/impl/card_test.clj b/test/metabase/revisions/impl/card_test.clj index a3619378b780..7591923eef70 100644 --- a/test/metabase/revisions/impl/card_test.clj +++ b/test/metabase/revisions/impl/card_test.clj @@ -185,7 +185,6 @@ :user_id (mt/user->id :rasta) :object old-card-data :message "Test revision without card_schema"}) - (testing "Can fetch revisions without error through API" (let [revisions (revision/revisions+details :model/Card card-id)] (is (seq revisions)) diff --git a/test/metabase/revisions/impl/dashboard_test.clj b/test/metabase/revisions/impl/dashboard_test.clj index 75d1aab770d6..08b0f86d3920 100644 --- a/test/metabase/revisions/impl/dashboard_test.clj +++ b/test/metabase/revisions/impl/dashboard_test.clj @@ -166,7 +166,6 @@ {:name "Apple"} {:name "Apple" :collection_id nil})))) - (mt/with-temp [:model/Collection {coll-id :id} {:name "New collection"}] (is (= "moved this Dashboard to New collection." @@ -260,10 +259,8 @@ ;; do the update (t2/update! :model/Dashboard (:id dashboard) changes) (create-dashboard-revision! (:id dashboard) false) - (testing (format "we should track when %s changes" col) (is (= 2 (t2/count :model/Revision :model "Dashboard" :model_id (:id dashboard))))) - ;; we don't need a description for made_public_by_id because whenever this field changes public_uuid ;; will changes and we had a description for it. Same is true for `archived_directly` - ;; `archived` will always change with it. @@ -306,10 +303,9 @@ (string? value) (str value "_changed")))] (doseq [col columns] (clean-revisions-for-dashboard (:id dashboard)) - ;; do the update + ;; do the update (t2/update! :model/DashboardCard (:id dashcard) {col (update-col col (get dashcard col))}) (create-dashboard-revision! (:id dashboard) false) - (testing (format "we should track when %s changes" col) (is (= 2 (t2/count :model/Revision :model "Dashboard" :model_id (:id dashboard))))))))))) @@ -332,10 +328,9 @@ (string? value) (str value "_changed")))] (doseq [col columns] (clean-revisions-for-dashboard (:id dashboard)) - ;; do the update + ;; do the update (t2/update! :model/DashboardTab (:id dashtab) {col (update-col col (get dashtab col))}) (create-dashboard-revision! (:id dashboard) false) - (testing (format "we should track when %s changes" col) (is (= 2 (t2/count :model/Revision :model "Dashboard" :model_id (:id dashboard))))))))))) @@ -471,8 +466,7 @@ (mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] ;; 0. create a dashboard (create-dashboard-revision! dashboard-id true) - -;; 1. add 2 tabs + ;; 1. add 2 tabs (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 :dashboard_id dashboard-id} @@ -486,7 +480,6 @@ :position 2 :dashboard_id dashboard-id}]))] (create-dashboard-revision! dashboard-id false) - ;; check to make sure we have everything setup before testing (is (=? [{:id tab-1-id :name "Tab 1" :position 0} {:id tab-2-id :name "Tab 2" :position 1} @@ -497,13 +490,11 @@ (is (=? [{:id tab-1-id :name "Tab 1" :position 0} {:id tab-2-id :name "Tab 2" :position 1}] (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]})))))) - (testing "revert renaming tabs" (mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] ;; 0. create a dashboard (create-dashboard-revision! dashboard-id true) - -;; 1. add 2 tabs + ;; 1. add 2 tabs (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 :dashboard_id dashboard-id} @@ -511,11 +502,9 @@ :position 1 :dashboard_id dashboard-id}])] (create-dashboard-revision! dashboard-id false) - ;; 2. update a tab name (t2/update! :model/DashboardTab tab-1-id {:name "Tab 1 with new name"}) (create-dashboard-revision! dashboard-id false) - ;; check to make sure we have everything setup before testing (is (=? [{:id tab-1-id :name "Tab 1 with new name" :position 0} {:id tab-2-id :name "Tab 2" :position 1}] @@ -525,13 +514,11 @@ (is (=? [{:id tab-1-id :name "Tab 1" :position 0} {:id tab-2-id :name "Tab 2" :position 1}] (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]})))))) - (testing "revert deleting tabs" (mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] - ;; 0. create a dashboard + ;; 0. create a dashboard (create-dashboard-revision! dashboard-id true) - -;; 1. add 2 tabs + ;; 1. add 2 tabs (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 :dashboard_id dashboard-id} @@ -539,16 +526,14 @@ :position 1 :dashboard_id dashboard-id}])] (create-dashboard-revision! dashboard-id false) - - ;; 2. delete the 1st tab and re-position the second tab + ;; 2. delete the 1st tab and re-position the second tab (t2/delete! :model/DashboardTab tab-1-id) (t2/update! :model/DashboardTab tab-2-id {:position 0}) (create-dashboard-revision! dashboard-id false) - - ;; check to make sure we have everything setup before testing + ;; check to make sure we have everything setup before testing (is (=? [{:id tab-2-id :name "Tab 2" :position 0}] (t2/select :model/DashboardTab :dashboard_id dashboard-id {:order-by [[:position :asc]]}))) - ;; revert + ;; revert (revert-to-previous-revision! :model/Dashboard dashboard-id 2) (is (=? [{:id (mt/malli=? [:fn pos-int?]) :name "Tab 1" :position 0} {:id tab-2-id :name "Tab 2" :position 1}] @@ -558,7 +543,6 @@ (mt/with-temp [:model/Dashboard {dashboard-id :id} {:name "A dashboard"}] ;; 0. create a dashboard (create-dashboard-revision! dashboard-id true) - ;; 1. add 2 tabs, each with 2 cards (let [[tab-1-id tab-2-id] (t2/insert-returning-pks! :model/DashboardTab [{:name "Tab 1" :position 0 @@ -592,7 +576,6 @@ :size_x 4 :size_y 4}])] (create-dashboard-revision! dashboard-id false) - ;; 2.a: tab 1: delete the 2nd card, add 2 cards and update position of one card, (t2/insert! :model/DashboardCard [{:dashboard_id dashboard-id :dashboard_tab_id tab-1-id @@ -608,10 +591,8 @@ :size_y 4}]) (t2/delete! :model/DashboardCard card-2-tab-1) (t2/update! :model/DashboardCard card-1-tab-1 {:row 10 :col 10}) - ;; 2.b: delete tab 2 (t2/delete! :model/DashboardTab tab-2-id) - ;; 2.c: create a new tab with 1 card (let [new-tab-id (t2/insert-returning-pks! :model/DashboardTab {:name "Tab 3" :position 1 @@ -623,8 +604,7 @@ :size_x 4 :size_y 4})) (create-dashboard-revision! dashboard-id false) - - ;; revert + ;; revert (revert-to-previous-revision! :model/Dashboard dashboard-id 2) (testing "tab 1 should have 2 cards" (is (= 2 (t2/count :model/DashboardCard :dashboard_tab_id tab-1-id))) @@ -632,14 +612,11 @@ (is (=? {:row 0 :col 0} (t2/select-one :model/DashboardCard card-1-tab-1))))) - (testing "tab \"Tab 2\" is restored" (let [new-tab-2 (t2/select-one :model/DashboardTab :dashboard_id dashboard-id :name "Tab 2")] (is (= 1 (:position new-tab-2))) - (testing "with its cards" (is (= 2 (t2/count :model/DashboardCard :dashboard_id dashboard-id :dashboard_tab_id (:id new-tab-2))))))) - (testing "there are no \"Tab 3\"" (is (false? (t2/exists? :model/DashboardTab :dashboard_id dashboard-id :name "Tab 3"))))))) @@ -681,14 +658,11 @@ :size_y 4 :visualization_settings {:text "Metabase"}}]) (create-dashboard-revision! dashboard-id false) - ;; 2. delete all the dashcards (t2/delete! :model/DashboardCard :dashboard_id dashboard-id) (create-dashboard-revision! dashboard-id false) - (t2/delete! :model/Card will-be-deleted-card) (t2/update! :model/Card :id will-be-archived-card {:archived true}) - (testing "revert should not include archived or deleted card ids (#34884)" (revert-to-previous-revision! :model/Dashboard dashboard-id 2) (is (=? #{{:card_id unchanged-card @@ -706,7 +680,6 @@ :model/Card {will-remain-card :id} {:name "Will remain question"}] ;; 0. create initial dashboard revision (create-dashboard-revision! dashboard-id true) - ;; 1. add two parameters - one with a card that will be deleted, one with a card that stays (t2/update! :model/Dashboard dashboard-id {:parameters [{:id "deleted-source" @@ -728,31 +701,25 @@ :slug "no_source" :type "category"}]}) (create-dashboard-revision! dashboard-id false) - ;; 2. remove the parameters (simulating user changing the filter settings) (t2/update! :model/Dashboard dashboard-id {:parameters []}) (create-dashboard-revision! dashboard-id false) - ;; 3. delete one card and archive another scenario - here we just delete (t2/delete! :model/Card value-source-card) - ;; 4. revert to the revision that had parameters referencing the now-deleted card (revert-to-previous-revision! :model/Dashboard dashboard-id 2) - (let [reverted-params (:parameters (t2/select-one :model/Dashboard :id dashboard-id))] (testing "parameter with deleted card source should have its source config removed" (let [deleted-source-param (first (filter #(= "deleted-source" (:id %)) reverted-params))] (is (some? deleted-source-param) "Parameter should still exist") (is (nil? (:values_source_type deleted-source-param)) "values_source_type should be removed") (is (nil? (:values_source_config deleted-source-param)) "values_source_config should be removed"))) - (testing "parameter with valid card source should remain unchanged" (let [valid-source-param (first (filter #(= "valid-source" (:id %)) reverted-params))] (is (some? valid-source-param) "Parameter should exist") (is (= :card (:values_source_type valid-source-param)) "values_source_type should be preserved") (is (= will-remain-card (get-in valid-source-param [:values_source_config :card_id])) "card_id should be preserved"))) - (testing "parameter without card source should remain unchanged" (let [no-source-param (first (filter #(= "no-source" (:id %)) reverted-params))] (is (some? no-source-param) "Parameter should exist") @@ -765,7 +732,6 @@ :model/Card {will-be-archived-card :id} {:name "Will be archived"}] ;; 0. create initial dashboard revision (create-dashboard-revision! dashboard-id true) - ;; 1. add a parameter with a card source (t2/update! :model/Dashboard dashboard-id {:parameters [{:id "archived-source" @@ -776,17 +742,13 @@ :values_source_config {:card_id will-be-archived-card :value_field [:field 1 nil]}}]}) (create-dashboard-revision! dashboard-id false) - ;; 2. remove the parameters (t2/update! :model/Dashboard dashboard-id {:parameters []}) (create-dashboard-revision! dashboard-id false) - ;; 3. archive the card (t2/update! :model/Card will-be-archived-card {:archived true}) - ;; 4. revert to the revision that had parameters referencing the now-archived card (revert-to-previous-revision! :model/Dashboard dashboard-id 2) - (let [reverted-params (:parameters (t2/select-one :model/Dashboard :id dashboard-id))] (testing "parameter with archived card source should have its source config removed" (let [archived-source-param (first reverted-params)] diff --git a/test/metabase/revisions/models/revision_test.clj b/test/metabase/revisions/models/revision_test.clj index 5873545d0196..606788689ae3 100644 --- a/test/metabase/revisions/models/revision_test.clj +++ b/test/metabase/revisions/models/revision_test.clj @@ -79,7 +79,6 @@ :model/Card {:name "Tips by State", :private false} {:name "Spots by State", :private false})))) - (is (= "made this Card private." (u/build-sentence ((get-method revision/diff-strings :default) @@ -95,7 +94,6 @@ :model/Card {:name "Tips by State", :private false} {:name "Spots by State", :private true}))))) - (testing "Check that several changes are handled nicely" (is (= "turned this to a model, made it private and renamed it from \"Tips by State\" to \"Spots by State\"." (u/build-sentence @@ -140,7 +138,6 @@ :message "yay!"})] (for [revision (revision/revisions ::FakedCard card-id)] (dissoc revision :timestamp :id :model_id)))))) - (testing "test that most_recent is correct" (mt/with-temp [:model/Card {card-id :id}] (doseq [i (range 3)] @@ -215,15 +212,12 @@ (testing "first revision should be recorded" (new-revision 1) (is (= 1 (count (revision/revisions ::FakedCard card-id))))) - (testing "repeatedly push reivisions with the same object shouldn't create new revision" (dorun (repeatedly 5 #(new-revision 1))) (is (= 1 (count (revision/revisions ::FakedCard card-id))))) - (testing "push a revision with different object should create new revision" (new-revision 2) (is (= 2 (count (revision/revisions ::FakedCard card-id))))))))) - (testing "Check that we don't record revision on dashboard if it has a filter" (mt/with-temp [:model/Dashboard {dash-id :id} {:parameters [{:name "Category Name" @@ -273,11 +267,9 @@ (-> (revision/add-revision-details ::FakedCard (first revisions) (last revisions)) (dissoc :timestamp :id :model_id) mt/derecordize)))))) - (testing "test that we return a description even when there is no change between revision" (is (= "created a revision with no change." (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} {:name "Apple"})))))) - (testing "that we return a descrtiopn when there is no previous revision" (is (= "modified this." (str (:description (revision/add-revision-details ::FakedCard {:name "Apple"} nil)))))))) @@ -439,7 +431,6 @@ {:object {:name "New Object"} :is_reversion false :is_creation true})))) - (testing "reversion" (is (= {:has_multiple_changes false :description "reverted to an earlier version."} @@ -450,7 +441,6 @@ {:object {:name "New Object"} :is_reversion true :is_creation false})))) - (testing "multiple changes" {:description "changed the display from table to bar and turned this into a model." :has_multiple_changes true} @@ -463,7 +453,6 @@ :display :bar} :is_reversion false :is_creation false})) - (testing "changes contains unspecified keys will not be mentioned" (is (= {:description "turned this to a model." :has_multiple_changes false} diff --git a/test/metabase/sample_data/impl_test.clj b/test/metabase/sample_data/impl_test.clj index 51637a213a3d..7c33ec16e97b 100644 --- a/test/metabase/sample_data/impl_test.clj +++ b/test/metabase/sample_data/impl_test.clj @@ -59,7 +59,6 @@ (with-temp-sample-database-db [db] (let [db-path (get-in db [:details :db])] (is (re-matches extracted-db-path-regex db-path)))))) - (testing "If the plugins directory is not creatable or writable, we fall back to reading from the DB in the JAR" (memoize/memo-clear! @#'plugins/plugins-dir*) (let [original-var u.files/create-dir-if-not-exists!] @@ -67,14 +66,12 @@ (with-temp-sample-database-db [db] (let [db-path (get-in db [:details :db])] (is (not (str/includes? db-path "plugins")))) - (testing "If the plugins directory is writable on a subsequent startup, the sample DB is copied" (with-redefs [u.files/create-dir-if-not-exists! original-var] (memoize/memo-clear! @#'plugins/plugins-dir*) (sample-data/update-sample-database-if-needed! db) (let [db-path (get-in (t2/select-one :model/Database :id (:id db)) [:details :db])] (is (re-matches extracted-db-path-regex db-path))))))))) - (memoize/memo-clear! @#'plugins/plugins-dir*)) (deftest sync-sample-database-test diff --git a/test/metabase/search/api_test.clj b/test/metabase/search/api_test.clj index 0a35f1dd9d36..392451c792cf 100644 --- a/test/metabase/search/api_test.clj +++ b/test/metabase/search/api_test.clj @@ -377,13 +377,11 @@ (let [resp (search-request :crowberto :q "test" :search_engine "appdb" :limit 1)] ;; The index is not populated here, so there's not much interesting to assert. (is (= "search.engine/appdb" (:engine resp)))))) - (testing "It can use the old search engine name, e.g. for old cookies" (search/init-index! {:force-reset? false :re-populate? false}) (with-search-items-in-root-collection "test" (let [resp (search-request :crowberto :q "test" :search_engine "fulltext" :limit 1)] (is (= "search.engine/fulltext" (:engine resp)))))) - (testing "It will not use an unknown search engine" (search/init-index! {:force-reset? false :re-populate? false}) (with-search-items-in-root-collection "test" @@ -441,7 +439,6 @@ (testing "return a subset of model for created_at filter" (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card" "metric" "measure"} (get-available-models :q search-term :created_at "today")))) - (testing "return a subset of model for search_native_query filter" (is (= #{"dataset" "action" "card" "metric"} (get-available-models :q search-term :search_native_query true))))))) @@ -676,10 +673,8 @@ :user_id (mt/user->id :rasta)}] (is (= (default-results-with-collection) (search-request-data :crowberto :q "test")))))) - ;; TODO need to isolate these two tests properly, they're sharing temp index (search/reindex! {:async? false :in-place? true}) - (testing "Basic search, should find 1 of each entity type and include bookmarks when available" (with-search-items-in-collection {:keys [card dashboard]} "test" (mt/with-temp [:model/CardBookmark _ {:card_id (u/the-id card) @@ -727,11 +722,9 @@ search! (fn [search-term] (:data (make-search-request :crowberto [:q search-term])))] (model-index/add-values! model-index) - (is (= #{"Dallas-Fort Worth" "Fort Lauderdale" "Fort Myers" "Fort Worth" "Fort Smith" "Fort Wayne"} (into #{} (comp relevant (map :name)) (search! "fort")))) - (let [normalize (fn [x] (-> x (update :pk_ref mbql.normalize/normalize) clean-result))] (is (=? {"Rome" {:pk_ref (mt/$ids $municipality.id) :name "Rome" @@ -772,7 +765,6 @@ normalize (fn [x] (-> x (update :pk_ref mbql.normalize/normalize)))] (model-index/add-values! model-index-1) (model-index/add-values! model-index-2) - (testing "Indexed entities returned if a non-admin user has full data perms and collection access" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :query-builder-and-native}} @@ -783,20 +775,17 @@ :model_index_id (mt/malli=? :int)}} (into {} (comp relevant-1 (map (juxt :name normalize))) (search! "rome" :rasta)))))) - (testing "Indexed entities are not returned if a user doesn't have full data perms for the DB" (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :no}} (is (= #{} (into #{} (comp relevant-1 (map (juxt :name normalize))) (search! "rom" :rasta))))) - (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries :query-builder}} (is (= #{} (into #{} (comp relevant-1 (map (juxt :name normalize))) (search! "rom" :rasta))))) - (let [[id-1 id-2 id-3 id-4] (map u/the-id (database/tables (mt/db)))] (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :unrestricted :create-queries {"PUBLIC" {id-1 :query-builder @@ -806,25 +795,21 @@ (is (= #{} (into #{} (comp relevant-1 (map (juxt :name normalize))) (search! "rom" :rasta)))))) - (mt/with-additional-premium-features #{:advanced-permissions} (mt/with-all-users-data-perms-graph! {(mt/id) {:view-data :blocked :create-queries :no}} (is (= #{} (into #{} (comp relevant-1 (map (juxt :name normalize))) (search! "rom" :rasta))))))) - (testing "Indexed entities are not returned if a user doesn't have root collection access" (mt/with-non-admin-groups-no-root-collection-perms (is (= #{} (into #{} (comp relevant-1 (map (juxt :name normalize))) (search! "rom" :rasta))))) - (mt/with-non-admin-groups-no-collection-perms collection (is (= #{} (into #{} (comp relevant-2 (map (juxt :name normalize))) (search! "rom" :rasta)))))) - (testing "Sandboxed users do not see indexed entities in search" (with-redefs [perms-util/impersonated-user? (constantly true)] (is (empty? (into #{} (comp relevant-1 (map :name)) (search! "fort"))))) @@ -1099,20 +1084,16 @@ :model/Card {model-id :id} {:name (format "%s Dataset 1" search-term) :type :model :creator_id user-id} :model/Dashboard {dashboard-id :id} {:name (format "%s Dashboard 1" search-term) :creator_id user-id} :model/Action {action-id :id} {:name (format "%s Action 1" search-term) :model_id model-id :creator_id user-id :type :http}] - (testing "sanity check that without search by created_by we have more results than if a filter is provided" (is (> (:total (mt/user-http-request :crowberto :get 200 "search" :q search-term)) 5))) - (testing "Able to filter by creator" (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :calculate_available_models true)] - (testing "only a subset of models are applicable" (is (= #{"card" "dataset" "dashboard" "action"} (set (:available_models resp))))) - (testing "results contains only entities with the specified creator" (is (= #{[dashboard-id "dashboard" "Created by Filter Dashboard 1"] [card-id "card" "Created by Filter Card 1"] @@ -1122,17 +1103,14 @@ (->> (:data resp) (map (juxt :id :model :name)) set)))))) - (testing "Able to filter by multiple creators" (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :created_by user-id-2 :calculate_available_models true)] - (testing "only a subset of models are applicable" (is (= #{"card" "dataset" "dashboard" "action"} (set (:available_models resp))))) - (testing "results contains only entities with the specified creator" (is (= #{[dashboard-id "dashboard" "Created by Filter Dashboard 1"] [card-id "card" "Created by Filter Card 1"] @@ -1143,13 +1121,11 @@ (->> (:data resp) (map (juxt :id :model :name)) set)))))) - (testing "Works with archived filter" (is (=? [{:model "card" :id card-id-3 :archived true}] (:data (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :archived true))))) - (testing "Works with models filter" (testing "return intersections of supported models with provided models" (is (= #{"dashboard" "card"} @@ -1157,14 +1133,12 @@ :data (map :model) set)))) - (testing "return nothing if there is no intersection" (is (= #{} (->> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_by user-id :models "table" :models "database") :data (map :model) set))))) - (testing "respect the read permissions" (let [resp (mt/user-http-request :rasta :get 200 "search" :q search-term :created_by user-id)] (is (not (contains? @@ -1173,7 +1147,6 @@ (map :id) set) card-id-2))))) - (testing "error if creator_id is not an integer" (let [resp (mt/user-http-request :crowberto :get 400 "search" :q search-term :created_by "not-a-valid-user-id")] (is (= {:created_by "nullable vector of value must be an integer greater than zero."} @@ -1204,16 +1177,13 @@ :object (merge {:id id} (when (= model :model/Card) {:type "question"}))})) - (testing "Able to filter by last editor" (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_by rasta-user-id :calculate_available_models true)] - (testing "only a subset of models are applicable" (is (= #{"dashboard" "dataset" "metric" "card"} (set (:available_models resp))))) - (testing "results contains only entities with the specified creator" (is (= #{[rasta-metric-id "metric"] [rasta-card-id "card"] @@ -1222,17 +1192,14 @@ (->> (:data resp) (map (juxt :id :model)) set)))))) - (testing "Able to filter by multiple last editor" (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :last_edited_by rasta-user-id :last_edited_by lucky-user-id :calculate_available_models true)] - (testing "only a subset of models are applicable" (is (= #{"dashboard" "dataset" "metric" "card"} (set (:available_models resp))))) - (testing "results contains only entities with the specified creator" (is (= #{[rasta-metric-id "metric"] [rasta-card-id "card"] @@ -1245,7 +1212,6 @@ (->> (:data resp) (map (juxt :id :model)) set)))))) - (testing "error if last_edited_by is not an integer" (let [resp (mt/user-http-request :crowberto :get 400 "search" :q search-term :last_edited_by "not-a-valid-user-id")] (is (= {:last_edited_by "nullable vector of value must be an integer greater than zero."} @@ -1272,23 +1238,18 @@ :data (filter #(= {:model "card" :id v-card-id} (select-keys % [:model :id]))) count)))) - (testing "only a subset of models are applicable" (is (= #{"card" "dataset" "dashboard"} (set (:available_models resp))))) - (testing "results contains only verified entities" (is (= #{[v-card-id "card" "Verified filter Verified Card"] [v-model-id "dataset" "Verified filter Verified Model"] [v-dash-id "dashboard" "Verified filter Verified Dashboard"]} - (->> (:data resp) (map (juxt :id :model :name)) set)))))) - (testing "Returns schema error if attempt to search for non-verified items" (is (= {:verified "nullable true"} (:errors (mt/user-http-request :crowberto :get 400 "search" :q "x" :verified false))))) - (testing "Works with models filter" (testing "return intersections of supported models with provided models" (is (= #{"card" "dashboard"} @@ -1297,17 +1258,14 @@ :data (map :model) set)))))) - (mt/with-premium-features #{:content-verification} (testing "Returns verified cards and models only if :content-verification is enabled" (let [resp (mt/user-http-request :crowberto :get 200 "search" :q search-term :verified true :calculate_available_models true)] - (testing "only a subset of models are applicable" (is (= #{"card" "dataset" "dashboard"} (set (:available_models resp))))) - (testing "results contains only verified entities" (is (= #{[v-card-id "card" "Verified filter Verified Card"] [v-model-id "dataset" "Verified filter Verified Model"] @@ -1315,7 +1273,6 @@ (->> (:data resp) (map (juxt :id :model :name)) set))))))) - (testing "error if doesn't have premium-features" (mt/with-premium-features #{} (mt/assert-has-premium-feature-error @@ -1330,14 +1287,12 @@ (set %))} (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_at "today" :calculate_available_models true)))) - (testing "works with others filter too" (is (= #{"dashboard" "table" "dataset" "collection" "database" "action" "card" "metric" "measure"} (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :created_at "today" :creator_id (mt/user->id :rasta) :calculate_available_models true) :available_models set)))) - (testing "error if invalids created_at string" (is (= "Failed to parse datetime value: today~" (mt/user-http-request :crowberto :get 400 "search" :q search-term :created_at "today~" :creator_id (mt/user->id :rasta)))))))) @@ -1373,12 +1328,10 @@ (->> (:data resp) (map (juxt :id :model)) set))) - (is (= #{"action" "card" "dashboard" "dataset" "metric"} (-> resp :available_models set))))) - (testing "works with the last_edited_by filter too" (doseq [[model id] [[:model/Card card-id] [:model/Card model-id] [:model/Dashboard dash-id] [:model/Card metric-id]]] @@ -1395,7 +1348,6 @@ :calculate_available_models true) :available_models set)))) - (testing "error if invalids last_edited_at string" (is (= "Failed to parse datetime value: today~" (mt/user-http-request :crowberto :get 400 "search" :q search-term :last_edited_at "today~" :creator_id (mt/user->id :rasta)))))))) @@ -1425,7 +1377,6 @@ :calculate_available_models true) :available_models set))) - (is (= #{"dashboard" "dataset" "segment" "measure" "collection" "action" "metric" "card" "table" "database"} (-> (mt/user-http-request :crowberto :get 200 "search" :q search-term :models "card" :models "dashboard" :calculate_available_models true) @@ -1447,7 +1398,7 @@ [_ {:type :model :dataset_query (mt/mbql-query venues)} {http-action :action-id} {:type :http :name search-term} {query-action :action-id} {:type :query :dataset_query (mt/native-query {:query (format "delete from %s" search-term)})}] - ;; TODO investigate why the actions don't get indexed automatically + ;; TODO investigate why the actions don't get indexed automatically (search/reindex! {:async? false :in-place? true}) (testing "by default do not search for native content" (is (= #{["card" mbql-card] @@ -1578,7 +1529,6 @@ :authority_level nil :type nil} (-> result :data first :collection)))) - (perms/revoke-collection-permissions! (perms/all-users-group) coll-2) (let [result (mt/user-http-request :rasta :get 200 "search" :q "Collection 3" :models ["collection"])] (is (= {:id (u/the-id coll-1) @@ -1586,7 +1536,6 @@ :authority_level nil :type nil} (-> result :data first :collection)))) - (perms/revoke-collection-permissions! (perms/all-users-group) coll-1) (let [result (mt/user-http-request :rasta :get 200 "search" :q "Collection 3" :models ["collection"])] (is (= {:id "root" @@ -1818,13 +1767,11 @@ :model/Card {reg-card-id :id} {:name (named "regular card")} ;; DQs aren't searchable without a DashboardCard (see later test) :model/DashboardCard _ {:dashboard_id dash-id :card_id card-id}] - ;; We need to update the entry for the card once the join is created. ;; This is not necessary in the real app because of how the index updates are batched. ;; Another solution would be to explicitly mark this data dependency, which we explicitly chose not to do for ;; now (see note of the Card spec). (search/update! (t2/instance :model/Card {:id card-id})) - (testing "The card data also include `dashboard` info" (is (= {:id dash-id :name (named "dashboard") @@ -1889,13 +1836,11 @@ (search-request :crowberto :q "test") (is (= 1 (count (filter #{:metabase-search/response-ok} @calls)))) (is (= 0 (count (filter #{:metabase-search/response-error} @calls))))) - (testing "Bad request (400)" (mt/user-http-request :crowberto :get 400 "/search" :archived "meow") (is (= 1 (count (filter #{:metabase-search/response-ok} @calls)))) ;; We do not treat client side errors as errors for our alerts. (is (= 0 (count (filter #{:metabase-search/response-error} @calls))))) - (testing "Unexpected server error (500)" (mt/with-dynamic-fn-redefs [search/search (fn [& _] (throw (Exception.)))] (mt/user-http-request :crowberto :get 500 "/search" :q "test") @@ -1930,7 +1875,6 @@ (let [search-results (mt/user-http-request :crowberto :get 200 "search" :q card-name)] (is (some #(= (:id %) card-id) (:data search-results)) "Card should be found in search results before database deletion"))) - (testing "Card should be hidden from search after database deletion" (t2/delete! :model/Database :id db-id) (is (not (t2/exists? :model/Card :id card-id))) @@ -1956,21 +1900,17 @@ (let [results (mt/user-http-request :crowberto :get 200 "search" :collection parent-coll)] (is (= #{parent-card parent-dash child-card grandchild-card parent-coll child-coll grandchild-coll} (set (map :id (:data results))))))) - (testing "Filter by child collection returns child and descendants only" (let [results (mt/user-http-request :crowberto :get 200 "search" :collection child-coll)] (is (= #{child-card grandchild-card child-coll grandchild-coll} (set (map :id (:data results))))))) - (testing "Filter by leaf collection returns only that collection's items" (let [results (mt/user-http-request :crowberto :get 200 "search" :collection grandchild-coll)] (is (= #{grandchild-card grandchild-coll} (set (map :id (:data results))))))) - (testing "Filter by non-existent collection returns no results" (let [results (mt/user-http-request :crowberto :get 200 "search" :collection 99999)] (is (empty? (:data results))))) - (testing "Items with no collection are not included when filtering by collection" (let [results (mt/user-http-request :crowberto :get 200 "search" :collection parent-coll)] (is (not (some #{other-card} (map :id (:data results))))))))) diff --git a/test/metabase/search/appdb/index_test.clj b/test/metabase/search/appdb/index_test.clj index 6444b9fdc734..aba8d07ea165 100644 --- a/test/metabase/search/appdb/index_test.clj +++ b/test/metabase/search/appdb/index_test.clj @@ -76,14 +76,13 @@ (let [fulltext? (= :postgres (mdb/db-type))] (with-index (testing "The index is updated when models change" - ;; Has a second entry is "Revenue Project(ions)", when using English dictionary + ;; Has a second entry is "Revenue Project(ions)", when using English dictionary (is (= (if fulltext? 2 1) (count (search.index/search "Projected Revenue")))) (is (= 0 (count (search.index/search "Protected Avenue")))) (t2/update! :model/Card {:name "Projected Revenue"} {:name "Protected Avenue"}) (is (= (if fulltext? 1 0) (count (search.index/search "Projected Revenue")))) (is (= 1 (count (search.index/search "Protected Avenue")))) - - ;; Delete hooks are remove for now, over performance concerns. + ;; Delete hooks are remove for now, over performance concerns. ;(t2/delete! :model/Card :name "Protected Avenue") #_(is (= 0 #_1 (count (search.index/search "Projected Revenue")))) #_(is (= 0 (count (search.index/search "Protected Avenue")))))))) @@ -107,16 +106,14 @@ (with-index (with-fulltext-filtering (testing "It does not match partial words" - ;; does not include revenue + ;; does not include revenue (is (= #{"venues"} (into #{} (comp (map second) (map u/lower-case-en)) (search.index/search "venue"))))) - - ;; no longer works without using the english dictionary + ;; no longer works without using the english dictionary (testing "Unless their lexemes are matching" (doseq [[a b] [["revenue" "revenues"] ["collect" "collection"]]] (is (= (search.index/search a) (search.index/search b))))) - (testing "Or we match a completion of the final word" (is (seq (search.index/search "sat"))) (is (seq (search.index/search "satisf"))) @@ -133,7 +130,7 @@ (is (<= 1 (index-hits "user")))) (testing "But stop words are skipped" (is (= 0 (index-hits "or"))) - ;; stop words depend on a dictionary + ;; stop words depend on a dictionary (is (= #_0 3 (index-hits "its the satisfaction of it")))) (testing "We can combine the individual results" (is (= (+ (index-hits "satisfaction") @@ -234,7 +231,6 @@ :last_editor_id nil :verified nil}) (ingest-then-fetch! model-type card-name)))))) - (testing (format "everything %s" model-type) (let [card-name (mt/random-name) yesterday (t/- (now) (t/days 1)) @@ -417,7 +413,6 @@ :timestamp two-days-ago :most_recent true :object {}}] - (is (=? (index-entity {:model "dashboard" :model_id (str dashboard-id) @@ -601,7 +596,6 @@ pending-old (search.index/gen-table-name) pending-new (search.index/gen-table-name) version (search.spec/index-version-hash)] - ;; Set up old pending table (more than a day old) (search.index/create-table! pending-old) (search-index-metadata/create-pending! :appdb version pending-old) @@ -609,18 +603,14 @@ {:index_name (name pending-old)} {:created_at (t/minus (t/offset-date-time) (t/days 2))}) (#'search.index/sync-tracking-atoms!) - (testing "Active table is returned" (is (= active-table (search.index/active-table)))) - (testing "Old pending table is ignored (more than a day old)" (is (nil? (#'search.index/pending-table)))) - ;; Create new pending table (less than a day old) (search.index/create-table! pending-new) (search-index-metadata/create-pending! :appdb version pending-new) (#'search.index/sync-tracking-atoms!) - (testing "New pending table is included (less than a day old)" (is (= active-table (search.index/active-table))) (is (= pending-new (#'search.index/pending-table))))) @@ -721,10 +711,8 @@ (try (let [table-name (search.index/gen-table-name) version (search.spec/index-version-hash)] - (testing "Nil age if no active table" (is (nil? (#'search.index/when-index-created)))) - (testing "Returns age of active table" (let [update-time (t/truncate-to (t/minus (t/offset-date-time) (t/days 2)) :millis)] (search.index/create-table! table-name) @@ -733,7 +721,6 @@ (t2/update! :model/SearchIndexMetadata :index_name (name table-name) {:created_at update-time}) - (is (= update-time (t/truncate-to (#'search.index/when-index-created) :millis)))))) (finally (t2/delete! :model/SearchIndexMetadata :version "index-age-test") diff --git a/test/metabase/search/appdb/scoring_test.clj b/test/metabase/search/appdb/scoring_test.clj index b8a53b9f2f58..8f8510df7ebf 100644 --- a/test/metabase/search/appdb/scoring_test.clj +++ b/test/metabase/search/appdb/scoring_test.clj @@ -96,10 +96,10 @@ {:model "card" :id 6 :name "ordering"}] (case (mdb/db-type) :postgres - ;; WARNING: this is likely to diverge between appdb types as we support more. + ;; WARNING: this is likely to diverge between appdb types as we support more. (testing "Preferences according to textual matches" - ;; Note that, ceteris paribus, the ordering in the database is currently stable - this might change! - ;; Due to stemming, we do not distinguish between exact matches and those that differ slightly. + ;; Note that, ceteris paribus, the ordering in the database is currently stable - this might change! + ;; Due to stemming, we do not distinguish between exact matches and those that differ slightly. (is (= [["card" 1 "orders"] ["card" 4 "order"] ;; We do not currently normalize the score based on the number of words in the vector / the coverage. @@ -200,7 +200,7 @@ card-with-view #(merge (mt/with-temp-defaults :model/Card) {:name search-term :view_count %}) - ;; Flake alert - we need to insert the outlier so that it is not chosen over the card it ties with. + ;; Flake alert - we need to insert the outlier so that it is not chosen over the card it ties with. ;; NOTE: we have brought in the outlier *a lot* to compensate for h2 not calculating a real percentile. outlier-card-id (t2/insert-returning-pk! :model/Card (card-with-view 88 #_100000)) _ (t2/insert! :model/Card (concat (repeatedly 20 #(card-with-view 0)) @@ -212,8 +212,8 @@ []) first-result-id (-> (search-results* search-term) first second)] (is (some? first-result-id)) - ;; Ideally we would make the outlier slightly less attractive in another way, with a weak weight, - ;; but we can solve this later if it actually becomes a flake + ;; Ideally we would make the outlier slightly less attractive in another way, with a weak weight, + ;; but we can solve this later if it actually becomes a flake (is (not= outlier-card-id first-result-id)))))))) (deftest ^:parallel dashboard-count-test @@ -249,7 +249,6 @@ (is (= [["card" c2 "card crowberto loved"] ["card" c1 "card normal"]] (search-results :bookmarked "card" {:current-user-id crowberto}))))))) - (mt/with-temp [:model/Dashboard {d1 :id} {} :model/Dashboard {d2 :id} {}] (testing "bookmarked dashboard" @@ -261,7 +260,6 @@ (is (= [["dashboard" d2 "dashboard crowberto loved"] ["dashboard" d1 "dashboard normal"]] (search-results :bookmarked "dashboard" {:current-user-id crowberto}))))))) - (mt/with-temp [:model/Collection {c1 :id} {} :model/Collection {c2 :id} {}] (testing "bookmarked collection" diff --git a/test/metabase/search/filter_test.clj b/test/metabase/search/filter_test.clj index e08745189ca8..9253078d2acf 100644 --- a/test/metabase/search/filter_test.clj +++ b/test/metabase/search/filter_test.clj @@ -66,19 +66,15 @@ (testing "All models (except transforms, which are admin-only) are relevant if we're not looking in the trash" (is (= (disj search.config/all-models "transform") (search.filter/search-context->applicable-models (with-all-models-and-regular-user {:archived? false}))))) - (testing "We only search for certain models in the trash" (is (= #{"dashboard" "dataset" "document" "segment" "measure" "collection" "action" "metric" "card"} (search.filter/search-context->applicable-models (with-all-models-and-regular-user {:archived? true}))))) - (testing "Indexed entities and transforms (which are admin-only) are not visible for sandboxed users" (is (= (disj search.config/all-models "indexed-entity" "transform") (search.filter/search-context->applicable-models (with-all-models-and-sandboxed-user {:archived? false}))))) - (testing "All models including transforms are visible for superusers" (is (= search.config/all-models (search.filter/search-context->applicable-models (with-all-models-and-superuser {:archived? false}))))) - (doseq [active-filters (active-filter-combinations)] (testing (str "Consistent models included when filtering on " (vec active-filters)) (let [search-ctx (with-all-models-and-regular-user (create-test-filter-context active-filters))] @@ -107,7 +103,6 @@ (mt/with-premium-features #{} (testing "The kitchen sink context is complete" (is (empty? (remove kitchen-sink-filter-context (filter-keys))))) - (testing "In the general case, we simply filter by models, and exclude dashboard cards" (is (= {:select [:some :stuff], :from :somewhere, @@ -123,7 +118,6 @@ [:in :search_index.model ["a"]] [:or [:= nil :search_index.dashboard_id] nil]]} (search.filter/with-filters {:models ["a"]} {:select [:some :stuff], :from :somewhere})))) - (testing "We can insert appropriate constraints for all the filters" (is (= {:select [:some :stuff], :from :somewhere, diff --git a/test/metabase/search/impl_test.clj b/test/metabase/search/impl_test.clj index d8f7110d4051..7f0929959d93 100644 --- a/test/metabase/search/impl_test.clj +++ b/test/metabase/search/impl_test.clj @@ -197,7 +197,6 @@ (test-search "2021-05-05~2023-05-04" new-result) (test-search "~2023-05-03" old-result) (test-search "2021-05-04T09:00:00~2021-05-04T10:00:10" old-result) - ;; relative times (test-search "thisyear" new-result) (test-search "past1years-from-12months" old-result) @@ -278,7 +277,6 @@ (test-search "2021-05-05~2023-05-04" new-result) (test-search "~2023-05-03" old-result) (test-search "2021-05-04T09:00:00~2021-05-04T10:00:10" old-result) - ;; relative times (test-search "thisyear" new-result) (test-search "past1years-from-12months" old-result) diff --git a/test/metabase/search/in_place/filter_test.clj b/test/metabase/search/in_place/filter_test.clj index 3a174111ab53..8b2f1441c3cd 100644 --- a/test/metabase/search/in_place/filter_test.clj +++ b/test/metabase/search/in_place/filter_test.clj @@ -47,61 +47,51 @@ (search.filter/search-context->applicable-models (merge default-search-ctx {:created-by #{1}})))) - (is (= #{"dashboard" "dataset"} (search.filter/search-context->applicable-models (merge default-search-ctx {:models #{"dashboard" "dataset" "table"} :created-by #{1}}))))) - (testing "created at" (is (= #{"dashboard" "table" "dataset" "document" "collection" "database" "action" "card" "metric" "transform" "measure"} (search.filter/search-context->applicable-models (merge default-search-ctx {:created-at "past3days"})))) - (is (= #{"dashboard" "table" "dataset"} (search.filter/search-context->applicable-models (merge default-search-ctx {:models #{"dashboard" "dataset" "table"} :created-at "past3days"}))))) - (testing "verified" (is (= #{"dashboard" "dataset" "card" "metric"} (search.filter/search-context->applicable-models (merge default-search-ctx {:verified true})))) - (is (= #{"dashboard" "dataset"} (search.filter/search-context->applicable-models (merge default-search-ctx {:models #{"dashboard" "dataset" "table"} :verified true}))))) - (testing "last edited by" (is (= #{"dashboard" "dataset" "card" "metric"} (search.filter/search-context->applicable-models (merge default-search-ctx {:last-edited-by #{1}})))) - (is (= #{"dashboard" "dataset"} (search.filter/search-context->applicable-models (merge default-search-ctx {:models #{"dashboard" "dataset" "table"} :last-edited-by #{1}}))))) - (testing "last edited at" (is (= #{"dashboard" "dataset" "action" "metric" "card"} (search.filter/search-context->applicable-models (merge default-search-ctx {:last-edited-at "past3days"})))) - (is (= #{"dashboard" "dataset"} (search.filter/search-context->applicable-models (merge default-search-ctx {:models #{"dashboard" "dataset" "table"} :last-edited-at "past3days"}))))) - (testing "search native query" (is (= #{"dataset" "action" "card" "metric" "transform"} (search.filter/search-context->applicable-models @@ -117,7 +107,6 @@ {:is-superuser? true :models #{"dashboard" "card" "transform"}})) "transform"))) - (testing "Non-superuser does not see transform in applicable models" (is (not (contains? (search.filter/search-context->applicable-models @@ -125,7 +114,6 @@ {:is-superuser? false :models #{"dashboard" "card" "transform"}})) "transform")))) - (testing "Non-superuser with transform in models set - transform is filtered out" (is (= #{"dashboard" "card"} (search.filter/search-context->applicable-models @@ -167,7 +155,6 @@ (is (= [:= :card.archived false] (:where (search.filter/build-filters base-search-query "card" default-search-ctx)))) - (is (= [:and [:= :table.active true] [:= :table.visibility_type nil] @@ -198,7 +185,7 @@ (mt/with-clock #t "2023-05-04T10:02:05Z[UTC]" (are [created-at expected-where] (= expected-where (#'search.filter/date-range-filter-clause :card.created_at created-at)) - ;; absolute datetime + ;; absolute datetime "Q1-2023" [:and [:>= [:cast :card.created_at :date] #t "2023-01-01"] [:< [:cast :card.created_at :date] #t "2023-04-01"]] "2016-04-18~2016-04-23" [:and [:>= [:cast :card.created_at :date] #t "2016-04-18"] @@ -213,7 +200,7 @@ [:< :card.created_at #t "2016-04-23T10:01"]] "2016-04-18T10:30:00~" [:> :card.created_at #t "2016-04-18T10:30"] "~2016-04-18T10:30:00" [:< :card.created_at #t "2016-04-18T10:31"] - ;; relative datetime + ;; relative datetime "past3days" [:and [:>= [:cast :card.created_at :date] #t "2023-05-01"] [:< [:cast :card.created_at :date] #t "2023-05-04"]] "past3days~" [:and [:>= [:cast :card.created_at :date] #t "2023-05-01"] @@ -262,7 +249,6 @@ (search.filter/build-filters base-search-query "dataset" (merge default-search-ctx {:last-edited-at "2016-04-18~2016-04-23"})))) - (testing "do not join twice if has both last-edited-at and last-edited-by" (is (= {:select [:*] :from [:table] @@ -278,7 +264,6 @@ base-search-query "dataset" (merge default-search-ctx {:last-edited-at "2016-04-18~2016-04-23" :last-edited-by #{1}}))))) - (testing "for actiion" (is (= {:select [:*] :from [:table] diff --git a/test/metabase/search/in_place/scoring_test.clj b/test/metabase/search/in_place/scoring_test.clj index b4b80764d1eb..caf729627136 100644 --- a/test/metabase/search/in_place/scoring_test.clj +++ b/test/metabase/search/in_place/scoring_test.clj @@ -303,15 +303,11 @@ (deftest ^:parallel force-weight-test (is (= [{:weight 10}] (scoring/force-weight [{:weight 1}] 10))) - (is (= [{:weight 5} {:weight 5}] (scoring/force-weight [{:weight 1} {:weight 1}] 10))) - (is (= [{:weight 0} {:weight 10}] (scoring/force-weight [{:weight 0} {:weight 1}] 10))) - (is (= 10 (count (scoring/force-weight (repeat 10 {:weight 1}) 10)))) (is (= #{[:weight 1]} (into #{} (first (scoring/force-weight (repeat 10 {:weight 1}) 10))))) - (is (= 100 (count (scoring/force-weight (repeat 100 {:weight 10}) 10)))) (is (= #{{:weight 1/10}} (into #{} (scoring/force-weight (repeat 100 {:weight 10}) 10))))) diff --git a/test/metabase/search/ingestion_test.clj b/test/metabase/search/ingestion_test.clj index a200a795b0ed..74b951059b70 100644 --- a/test/metabase/search/ingestion_test.clj +++ b/test/metabase/search/ingestion_test.clj @@ -29,7 +29,6 @@ (with-redefs [search.spec/spec spec-fn] (is (= "Test Name Test Description" (#'search.ingestion/searchable-text record)))))) - (testing "searchable-text with map format and transforms" (let [spec-fn (constantly {:search-terms {:name search.spec/explode-camel-case :description true}}) @@ -39,7 +38,6 @@ (with-redefs [search.spec/spec spec-fn] (is (= "CamelCaseTest Camel Case Test Simple description" (#'search.ingestion/searchable-text record)))))) - (testing "searchable-text filters out blank values" (let [spec-fn (constantly {:search-terms [:name :description :empty-field]}) record {:model "test" @@ -60,7 +58,6 @@ (with-redefs [search.spec/spec spec-fn] (is (= "[card]\nname: Sales Dashboard\ndescription: Shows quarterly sales data" (#'search.ingestion/embeddable-text record)))))) - (testing "embeddable-text with map format" (let [spec-fn (constantly {:search-terms {:name true :description true}}) @@ -70,7 +67,6 @@ (with-redefs [search.spec/spec spec-fn] (is (= "[dashboard]\nname: Test Dashboard\ndescription: A test dashboard" (#'search.ingestion/embeddable-text record)))))) - (testing "embeddable-text filters out blank values" (let [spec-fn (constantly {:search-terms [:name :description :empty-field]}) record {:model "card" @@ -80,7 +76,6 @@ (with-redefs [search.spec/spec spec-fn] (is (= "[card]\nname: Test Card" (#'search.ingestion/embeddable-text record)))))) - (testing "embeddable-text does not apply transform functions" (let [spec-fn (constantly {:search-terms {:name search.spec/explode-camel-case}}) record {:model "table" @@ -96,12 +91,10 @@ :provides [:has-temporal-dim :non-temporal-dim-ids]}}}] (is (= {:has_temporal_dim true :non_temporal_dim_ids "[1 2]"} (#'search.ingestion/execute-all-function-attrs spec {}))))) - (testing "function-attr without :provides falls back to writing snake_case attr-key on non-map results" (let [spec {:attrs {:native-query {:fn (constantly "SELECT 1")}}}] (is (= {:native_query "SELECT 1"} (#'search.ingestion/execute-all-function-attrs spec {}))))) - (testing "function-attr with :provides skips writing when result is not a map" (let [spec {:attrs {:temporal-info {:fn (fn [_] (throw (ex-info "boom" {}))) :provides [:has-temporal-dim :non-temporal-dim-ids]}}}] @@ -111,7 +104,6 @@ (testing "search-term-columns with vector format" (is (= #{:name :description} (set (#'search.ingestion/search-term-columns [:name :description]))))) - (testing "search-term-columns with map format" (is (= #{:name :description} (set (#'search.ingestion/search-term-columns {:name identity diff --git a/test/metabase/search/spec_test.clj b/test/metabase/search/spec_test.clj index c1cc41569c3a..1f296c7f4809 100644 --- a/test/metabase/search/spec_test.clj +++ b/test/metabase/search/spec_test.clj @@ -111,7 +111,6 @@ :where [:= :updated.id :this.id]}} :Collection #{{:search-model "collection" :fields #{:authority_level :archived :description :name :type :id - :archived_directly :location :namespace :created_at} :where [:= :updated.id :this.id]} {:search-model "table" diff --git a/test/metabase/search/task/search_index_test.clj b/test/metabase/search/task/search_index_test.clj index 4841c276183b..3d19f74a0597 100644 --- a/test/metabase/search/task/search_index_test.clj +++ b/test/metabase/search/task/search_index_test.clj @@ -16,10 +16,10 @@ (deftest index!-test (search.tu/with-temp-index-table - ;; TODO this is coupled to appdb engines at the moment + ;; TODO this is coupled to appdb engines at the moment (t2/query (sql.helpers/drop-table (search.index/active-table))) (testing "It can recreate the index from scratch" - ;; May return falsey if there is nothing to index. + ;; May return falsey if there is nothing to index. (is (task/init!)) (is (pos? (index-size)))) (testing "It will reuse an existing index" @@ -27,7 +27,7 @@ (deftest reindex!-test (search.tu/with-temp-index-table - ;; TODO this is coupled to appdb engines at the moment + ;; TODO this is coupled to appdb engines at the moment (t2/query (sql.helpers/drop-table (search.index/active-table))) (testing "It can recreate the index from scratch" (is (search/reindex! {:async? false})) diff --git a/test/metabase/search/test_util.clj b/test/metabase/search/test_util.clj index 6afa5d831505..1fbff5d6b2e4 100644 --- a/test/metabase/search/test_util.clj +++ b/test/metabase/search/test_util.clj @@ -26,7 +26,7 @@ [& body] `(when (search/supports-index?) (search.index/with-temp-index-table - ;; We need ingestion to happen on the same thread so that it uses the right search index. + ;; We need ingestion to happen on the same thread so that it uses the right search index. (with-sync-search-indexing ~@body)))) diff --git a/test/metabase/search/util_test.clj b/test/metabase/search/util_test.clj index 2066854019f7..705f6c440c32 100644 --- a/test/metabase/search/util_test.clj +++ b/test/metabase/search/util_test.clj @@ -30,22 +30,16 @@ (deftest to-tsquery-expr-test (is (= "'a' & 'b' & 'c':*" (search-expr "a b c"))) - (is (= "'a' & 'b' & 'c':*" (search-expr "a AND b AND c"))) - (is (= "'a' & 'b' & 'c'" (search-expr "a b \"c\""))) - (is (= "'a' & 'b' | 'c':*" (search-expr "a b or c"))) - (is (= "'a' | 'b':*" (search-expr "a or and or b"))) - (is (= "'this' & !'that':*" (search-expr "this -that"))) - (testing "hyphens" (is (= "'[ops' & 'monitoring]' & '-' & 'available':*" (search-expr "[ops monitoring] - available"))) @@ -53,32 +47,24 @@ (search-expr "[ops monitoring] -- available"))) (is (= "'[ops' & 'monitoring]' & 'not-available':*" (search-expr "[ops monitoring] not-available")))) - (is (= "'a' & 'b' & 'c' <-> 'd' & 'e' | 'b' & 'e':*" (search-expr "a b \" c d\" e or b e"))) - (is (= "'ab' <-> 'and' <-> 'cde' <-> 'f' | !'abc' & 'def' & 'ghi' | 'jkl' <-> 'mno' <-> 'or' <-> 'pqr'" (search-expr "\"ab and cde f\" or -abc def AND ghi OR \"jkl mno OR pqr\""))) - (is (= "'big' & 'data' | 'business' <-> 'intelligence' | 'data' & 'wrangling':*" (search-expr "Big Data oR \"Business Intelligence\" OR data and wrangling"))) - (testing "unbalanced quotes" (is (= "'big' <-> 'data' & 'big' <-> 'mistake':*" (search-expr "\"Big Data\" \"Big Mistake"))) (is (= "'something'" (search-expr "something \"")))) - (is (= "'partial' <-> 'quoted' <-> 'and' <-> 'or' <-> '-split':*" (search-expr "\"partial quoted AND OR -split"))) - (testing "dangerous characters" (is (= "'you' & '<-' & 'pointing':*" (search-expr "you <- pointing")))) - (testing "backslash" (is (= "'test\\\\':*" (search-expr "test\\")))) - (testing "single quotes" (is (= "'you''re':*" (search-expr "you're"))))) diff --git a/test/metabase/secrets/models/secret_test.clj b/test/metabase/secrets/models/secret_test.clj index 29a30eea3ba8..0140b484d25c 100644 --- a/test/metabase/secrets/models/secret_test.clj +++ b/test/metabase/secrets/models/secret_test.clj @@ -57,7 +57,6 @@ (testing "get-secret-string from value only" (is (= "titok" (secret/value-as-string :secret-test-driver {:keystore-value "titok"} "keystore")))) - (testing "get-secret-string from value only from the database" (mt/with-temp [:model/Secret {id :id} {:name "private-key" :kind ::secret/pem-cert @@ -65,7 +64,6 @@ :creator_id (mt/user->id :crowberto)}] (is (= "titok" (secret/value-as-string :secret-test-driver {:keystore-id id} "keystore"))))) - (testing "get-secret-string from value only from the database ignore protected-password **MetabasePass**" (mt/with-temp [:model/Secret {id :id} {:name "private-key" :kind ::secret/pem-cert @@ -73,7 +71,6 @@ :creator_id (mt/user->id :crowberto)}] (is (= "titok" (secret/value-as-string :secret-test-driver {:keystore-id id :keystore-value secret/protected-password} "keystore"))))) - (testing "get-secret-string from uploaded value" (mt/with-temp [:model/Secret {id :id} {:name "private-key" :kind ::secret/pem-cert @@ -94,13 +91,11 @@ "keystore")) "psszt!" (mt/bytes->base64-data-uri (.getBytes "psszt!" "UTF-8")))))) - (testing "get-secret-string from local file" (mt/with-temp-file [file-db "-1-key.pem" file-value "-2-key.pem"] (spit file-db "titok") (spit file-value "psszt!") - (testing "from value" (is (= "titok" (secret/value-as-string @@ -108,7 +103,6 @@ {:keystore-path file-db :keystore-options "local"} "keystore")))) - (testing "from the database" (mt/with-temp [:model/Secret {id :id} {:name "private-key" :kind ::secret/pem-cert diff --git a/test/metabase/segments/api_test.clj b/test/metabase/segments/api_test.clj index f8de61f85ce8..8c0162f68145 100644 --- a/test/metabase/segments/api_test.clj +++ b/test/metabase/segments/api_test.clj @@ -50,7 +50,6 @@ (deftest authentication-test (is (= (get api.response/response-unauthentic :body) (client/client :get 401 "segment"))) - (is (= (get api.response/response-unauthentic :body) (client/client :put 401 "segment/13")))) @@ -68,18 +67,14 @@ (testing "POST /api/segment" (is (=? {:errors {:name "value must be a non-blank string."}} (mt/user-http-request :crowberto :post 400 "segment" {}))) - (is (=? {:errors {:table_id "value must be an integer greater than zero."}} (mt/user-http-request :crowberto :post 400 "segment" {:name "abc"}))) - (is (=? {:errors {:table_id "value must be an integer greater than zero."}} (mt/user-http-request :crowberto :post 400 "segment" {:name "abc" :table_id "foobar"}))) - (is (=? {:errors {:definition "Value must be a map."}} (mt/user-http-request :crowberto :post 400 "segment" {:name "abc" :table_id 123}))) - (is (=? {:errors {:definition "Value must be a map."}} (mt/user-http-request :crowberto :post 400 "segment" {:name "abc" :table_id 123 @@ -127,14 +122,11 @@ (testing "PUT /api/segment/:id" (is (=? {:errors {:name "nullable value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "segment/1" {:name "" :revision_message "abc"}))) - (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "segment/1" {:name "abc"}))) - (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :put 400 "segment/1" {:name "abc" :revision_message ""}))) - (is (=? {:errors {:definition "nullable map"}} (mt/user-http-request :crowberto :put 400 "segment/1" {:name "abc" :revision_message "123" @@ -236,7 +228,6 @@ (testing "DELETE /api/segment/:id" (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :delete 400 "segment/1" {:name "abc"}))) - (is (=? {:errors {:revision_message "value must be a non-blank string."}} (mt/user-http-request :crowberto :delete 400 "segment/1" :revision_message ""))))) diff --git a/test/metabase/segments/models/segment_test.clj b/test/metabase/segments/models/segment_test.clj index ad40fd088531..162632100af2 100644 --- a/test/metabase/segments/models/segment_test.clj +++ b/test/metabase/segments/models/segment_test.clj @@ -71,13 +71,11 @@ Exception #"You cannot update the creator_id of a Segment" (t2/update! :model/Segment id {:creator_id (mt/user->id :crowberto)})))) - (testing "you shouldn't be able to set it to `nil` either" (is (thrown-with-msg? Exception #"You cannot update the creator_id of a Segment" (t2/update! :model/Segment id {:creator_id nil})))) - (testing "calling `update!` with a value that is the same as the current value shouldn't throw an Exception" (is (= 0 (t2/update! :model/Segment id {:creator_id (mt/user->id :rasta)}))))))) diff --git a/test/metabase/server/lib/etag_cache_test.clj b/test/metabase/server/lib/etag_cache_test.clj index c41745740b99..13a663061dc2 100644 --- a/test/metabase/server/lib/etag_cache_test.clj +++ b/test/metabase/server/lib/etag_cache_test.clj @@ -22,7 +22,6 @@ (is (= etag (get-in resp [:headers "ETag"]))) (is (nil? (get-in resp [:headers "Cache-Control"]))) (is (nil? (get-in resp [:headers "Content-Type"]))))) - (testing "Weak ETag (W/) is treated as match" (let [etag-weak (format "W/\"%s\"" config/mb-version-hash) resp (lib.etag-cache/with-etag (base-response) {:headers {"if-none-match" etag-weak}})] @@ -30,7 +29,6 @@ (is (= "" (:body resp))) (is (= (format "\"%s\"" config/mb-version-hash) (get-in resp [:headers "ETag"]))))) - (testing "Multiple ETags in If-None-Match; any match triggers 304" (let [header (format "\"other\", W/\"%s\", \"another\"" config/mb-version-hash) resp (lib.etag-cache/with-etag (base-response) {:headers {"if-none-match" header}})] @@ -50,7 +48,6 @@ (is (= "bar" (get-in resp [:headers "X-Foo"]))) (is (nil? (get-in resp [:headers "Cache-Control"]))) (is (nil? (get-in resp [:headers "Content-Type"]))))) - (testing "Missing If-None-Match → 200; adds ETag only" (let [resp (lib.etag-cache/with-etag (base-response) {:headers {}})] (is (= 200 (:status resp))) diff --git a/test/metabase/server/middleware/auth_test.clj b/test/metabase/server/middleware/auth_test.clj index 903562a8cdcd..bf0f022966b1 100644 --- a/test/metabase/server/middleware/auth_test.clj +++ b/test/metabase/server/middleware/auth_test.clj @@ -45,13 +45,11 @@ (-> (auth-enforced-handler (request-with-session-key session-key)) :metabase-user-id))) (finally (t2/delete! :model/Session :id session-id))))) - (testing "Invalid requests should return unauthed response" (testing "when no session ID is sent with request" (is (= api.response/response-unauthentic (auth-enforced-handler (ring.mock/request :get "/anyurl"))))) - (testing "when an expired session ID is sent with request" ;; create a new session (specifically created some time in the past so it's EXPIRED) should fail due to session ;; expiration @@ -67,7 +65,6 @@ (is (= api.response/response-unauthentic (auth-enforced-handler (request-with-session-key session-key)))) (finally (t2/delete! :model/Session :id session-id))))) - (testing "when a Session tied to an inactive User is sent with the request" ;; create a new session (specifically created some time in the past so it's EXPIRED) ;; should fail due to inactive user @@ -101,7 +98,6 @@ (:metabase-session-key (wrapped-api-key-handler (ring.mock/request :get "/anyurl")))))) - (testing "API Key in header" (is (= "foobar" (:static-metabase-api-key diff --git a/test/metabase/server/middleware/embedding_sdk_bundle_test.clj b/test/metabase/server/middleware/embedding_sdk_bundle_test.clj index e36baef06b62..370d32bdfbfd 100644 --- a/test/metabase/server/middleware/embedding_sdk_bundle_test.clj +++ b/test/metabase/server/middleware/embedding_sdk_bundle_test.clj @@ -83,7 +83,6 @@ resp (handler {:headers {}})] (is (= 200 (:status resp))) (is (str/includes? @requested-resource "legacy/")))))) - (testing "packageVersion present → serves bootstrap resource" (let [requested-resource (atom nil)] (with-redefs [config/is-prod? false @@ -95,7 +94,6 @@ :query-params {"packageVersion" "0.59.0"}})] (is (= 200 (:status resp))) (is (str/includes? @requested-resource "chunks/")))))) - (testing "packageVersion + useLegacyMonolithicBundle=true → serves legacy resource" (let [requested-resource (atom nil)] (with-redefs [config/is-prod? false @@ -120,7 +118,6 @@ (is (= far-future-cache-header (get-in resp [:headers "Cache-Control"]))) (is (= js-ct (get-in resp [:headers "Content-Type"]))) (is (= "Accept-Encoding" (get-in resp [:headers "Vary"])))))) - (testing "Missing chunk resource → 404" (with-redefs [response/resource-response (constantly nil)] (let [handler (mw.embedding-sdk-bundle/serve-chunk-handler "embedding-sdk-chunk-nonexistent.js") diff --git a/test/metabase/server/middleware/exceptions_test.clj b/test/metabase/server/middleware/exceptions_test.clj index 166918b5d60d..01b343430fa7 100644 --- a/test/metabase/server/middleware/exceptions_test.clj +++ b/test/metabase/server/middleware/exceptions_test.clj @@ -200,7 +200,6 @@ (let [initial (mt/metric-value system :metabase-api/unhandled-errors)] (mw.exceptions/api-exception-response (Exception. "boom")) (is (< initial (mt/metric-value system :metabase-api/unhandled-errors)))))) - (testing "An exception with an explicit status-code does NOT increment the counter" (mt/with-prometheus-system! [_ system] (let [initial (mt/metric-value system :metabase-api/unhandled-errors)] diff --git a/test/metabase/server/middleware/log_test.clj b/test/metabase/server/middleware/log_test.clj index 02294c578a55..c9511c0a000f 100644 --- a/test/metabase/server/middleware/log_test.clj +++ b/test/metabase/server/middleware/log_test.clj @@ -28,7 +28,6 @@ (is (#'mw.log/should-log-request? {:uri "/api/health"})) (is (#'mw.log/should-log-request? {:uri "/livez"})) (is (#'mw.log/should-log-request? {:uri "/readyz"}))) - (mt/with-temp-env-var-value! [mb-health-check-logging-enabled false] (is (not (#'mw.log/should-log-request? {:uri "/api/health"}))) (is (not (#'mw.log/should-log-request? {:uri "/livez"}))) diff --git a/test/metabase/server/middleware/sdk_test.clj b/test/metabase/server/middleware/sdk_test.clj index 9ae0a1b03d1e..a5f95fe0eeaf 100644 --- a/test/metabase/server/middleware/sdk_test.clj +++ b/test/metabase/server/middleware/sdk_test.clj @@ -144,7 +144,7 @@ (deftest embeding-mw-does-not-bump-metrics-with-random-sdk-header (let [prometheus-standin (atom {})] (with-redefs [analytics/inc! (fn [k _] (swap! prometheus-standin update k (fnil inc 0)))] - ;; has X-Metabase-Client header, but it's not the SDK, so we don't track it + ;; has X-Metabase-Client header, but it's not the SDK, so we don't track it (let [request (mock-request {:client "my-client"}) good (analytics.core/embedding-mw (fn [_ respond _] (respond {:status 200}))) bad (analytics.core/embedding-mw (fn [_ respond _] (respond {:status 400}))) diff --git a/test/metabase/server/middleware/security_mcp_test.clj b/test/metabase/server/middleware/security_mcp_test.clj index ea6f9ffd70d4..f42c2a4677bf 100644 --- a/test/metabase/server/middleware/security_mcp_test.clj +++ b/test/metabase/server/middleware/security_mcp_test.clj @@ -31,15 +31,12 @@ (testing "Claude sandbox origins should be allowed when claude is enabled" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["claude"]] (assert-cors-allowed! "https://abc.claudemcpcontent.com"))) - (testing "ChatGPT sandbox origins should be allowed when chatgpt is enabled" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["chatgpt"]] (assert-cors-allowed! "https://abc.web-sandbox.oaiusercontent.com"))) - (testing "Claude sandbox origins should NOT be allowed when only chatgpt is enabled" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["chatgpt"]] (assert-cors-blocked! "https://abc.claudemcpcontent.com"))) - (testing "Multiple MCP clients can be enabled simultaneously" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["claude" "chatgpt"]] (assert-cors-allowed! "https://abc.claudemcpcontent.com") @@ -49,7 +46,6 @@ (testing "vscode-webview:// origins should be allowed when vscode is enabled" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["cursor-vscode"]] (assert-cors-allowed! "vscode-webview://abc123"))) - (testing "vscode-webview:// origins should NOT be allowed when vscode is not enabled" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["claude"]] (assert-cors-blocked! "vscode-webview://abc123")))) @@ -58,7 +54,6 @@ (testing "Custom MCP origins should be allowed" (mt/with-temporary-setting-values [mcp-apps-cors-custom-origins "https://my-librechat.example.com"] (assert-cors-allowed! "https://my-librechat.example.com"))) - (testing "Custom MCP origins should work alongside common MCP origins" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["claude"] mcp-apps-cors-custom-origins "https://my-librechat.example.com"] @@ -78,14 +73,12 @@ (let [origins (mcp.settings/mcp-apps-cors-origins)] (is (str/includes? origins "*.claudemcpcontent.com")) (is (str/includes? origins "*.web-sandbox.oaiusercontent.com"))))) - (testing "mcp-apps-cors-origins includes custom origins" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients ["claude"] mcp-apps-cors-custom-origins "https://custom.example.com"] (let [origins (mcp.settings/mcp-apps-cors-origins)] (is (str/includes? origins "*.claudemcpcontent.com")) (is (str/includes? origins "https://custom.example.com"))))) - (testing "mcp-apps-cors-origins returns empty string when nothing is configured" (mt/with-temporary-setting-values [mcp-apps-cors-enabled-clients [] mcp-apps-cors-custom-origins ""] diff --git a/test/metabase/server/middleware/security_test.clj b/test/metabase/server/middleware/security_test.clj index 8b61a74c6a12..4e9972cdd785 100644 --- a/test/metabase/server/middleware/security_test.clj +++ b/test/metabase/server/middleware/security_test.clj @@ -373,7 +373,6 @@ [2 ["" "http://localhost:1234" "http://www.a-site.com" "http://localhost:1234"]] [3 ["" "http://my-site.com" "http://public.metabase.com" nil]] [4 ["" "http://my-site.com" "http://www.a-site.com" nil]] - ;; CORS origins configured = CORS enabled via origins [5 ["localhost:1234" "http://localhost:1234" "http://public.metabase.com" "http://localhost:1234"]] [6 ["localhost:1234" "http://localhost:1234" "http://www.a-site.com" "http://localhost:1234"]] @@ -438,7 +437,6 @@ "Should set Access-Control-Allow-Headers to * for /auth/sso with 402 status") (is (= "*" (get-in response [:headers "Access-Control-Allow-Methods"])) "Should set Access-Control-Allow-Methods to * for /auth/sso with 402 status"))) - (testing "Should add CORS headers for /auth/sso endpoint with 400 status (client errors)" (let [wrapped-handler (mw.security/add-security-headers (fn [_request respond _raise] @@ -454,7 +452,6 @@ "Should set Access-Control-Allow-Headers to * for /auth/sso with 400 status") (is (= "*" (get-in response [:headers "Access-Control-Allow-Methods"])) "Should set Access-Control-Allow-Methods to * for /auth/sso with 400 status"))) - (testing "Should add CORS headers for /auth/sso OPTIONS requests (preflight)" (let [wrapped-handler (mw.security/add-security-headers (fn [_request respond _raise] @@ -472,7 +469,6 @@ "Should set Access-Control-Allow-Headers to * for OPTIONS /auth/sso") (is (= "*" (get-in response [:headers "Access-Control-Allow-Methods"])) "Should set Access-Control-Allow-Methods to * for OPTIONS /auth/sso"))) - (testing "Should not add CORS headers for /auth/sso endpoint with other status codes" (doseq [status [200 201 500 503]] (let [wrapped-handler (mw.security/add-security-headers @@ -493,46 +489,39 @@ (let [headers (mw.security/security-headers)] (is (= "cross-origin" (get headers "Cross-Origin-Resource-Policy")) "Should include Cross-Origin-Resource-Policy header when env var is set")))) - (testing "Should not add header when MB_CROSS_ORIGIN_RESOURCE_POLICY is not set" (with-redefs [env/env {}] (let [headers (mw.security/security-headers)] (is (nil? (get headers "Cross-Origin-Resource-Policy")) "Should not include Cross-Origin-Resource-Policy header when env var is not set"))))) - (testing "Cross-Origin-Embedder-Policy header from environment variable" (testing "Should add header when MB_CROSS_ORIGIN_EMBEDDER_POLICY is set" (with-redefs [env/env {:mb-cross-origin-embedder-policy "require-corp"}] (let [headers (mw.security/security-headers)] (is (= "require-corp" (get headers "Cross-Origin-Embedder-Policy")) "Should include Cross-Origin-Embedder-Policy header when env var is set")))) - (testing "Should not add header when MB_CROSS_ORIGIN_EMBEDDER_POLICY is not set" (with-redefs [env/env {}] (let [headers (mw.security/security-headers)] (is (nil? (get headers "Cross-Origin-Embedder-Policy")) "Should not include Cross-Origin-Embedder-Policy header when env var is not set"))))) - (testing "Both Cross-Origin headers can be set independently" (testing "Only CORP set" (with-redefs [env/env {:mb-cross-origin-resource-policy "same-origin"}] (let [headers (mw.security/security-headers)] (is (= "same-origin" (get headers "Cross-Origin-Resource-Policy"))) (is (nil? (get headers "Cross-Origin-Embedder-Policy")))))) - (testing "Only COEP set" (with-redefs [env/env {:mb-cross-origin-embedder-policy "credentialless"}] (let [headers (mw.security/security-headers)] (is (nil? (get headers "Cross-Origin-Resource-Policy"))) (is (= "credentialless" (get headers "Cross-Origin-Embedder-Policy")))))) - (testing "Both set" (with-redefs [env/env {:mb-cross-origin-resource-policy "same-site" :mb-cross-origin-embedder-policy "require-corp"}] (let [headers (mw.security/security-headers)] (is (= "same-site" (get headers "Cross-Origin-Resource-Policy"))) (is (= "require-corp" (get headers "Cross-Origin-Embedder-Policy"))))))) - (testing "Cross-Origin headers are included in middleware response" (testing "Headers are present in actual HTTP response" (with-redefs [env/env {:mb-cross-origin-resource-policy "cross-origin" diff --git a/test/metabase/server/middleware/session_test.clj b/test/metabase/server/middleware/session_test.clj index 4261a67beaca..6a770a0e6c44 100644 --- a/test/metabase/server/middleware/session_test.clj +++ b/test/metabase/server/middleware/session_test.clj @@ -277,7 +277,6 @@ (mt/with-premium-features #{} (is (= false (:is-group-manager? (#'mw.session/current-user-info-for-session test-session-key nil)))))) - (testing "is `true` if advanced-permisison is enabled" ;; a trick to run this test in OSS because even if advanced-permisison is enabled but EE ns is not evailable ;; `enable-advanced-permissions?` will still return false @@ -298,7 +297,6 @@ (#'mw.session/current-user-info-for-session test-session-key nil))) (finally (t2/delete! :model/Session :id test-session-id))) - (testing "...but if we do specifiy the token, they should come back" (try (t2/insert! :model/Session {:id test-session-id @@ -314,7 +312,6 @@ (#'mw.session/current-user-info-for-session test-session-key test-anti-csrf-token))) (finally (t2/delete! :model/Session :id test-session-id))) - (testing "(unless the token is wrong)" (try (t2/insert! :model/Session {:id test-session-id @@ -436,7 +433,6 @@ (testing "No Session" (is (= nil (session-locale nil)))) - (testing "w/ Session" (testing "for user with no `:locale`" (mt/with-temp [:model/User {user-id :id}] @@ -446,11 +442,9 @@ (t2/insert! :model/Session {:id session-id :key_hashed session-key-hashed, :user_id user-id}) (is (= nil (session-locale session-key))) - (testing "w/ X-Metabase-Locale header" (is (= "es_MX" (session-locale session-key :headers {"x-metabase-locale" "es-mx"}))))))) - (testing "for user *with* `:locale`" (mt/with-temp [:model/User {user-id :id} {:locale "es-MX"}] (let [session-id (session/generate-session-id) @@ -459,7 +453,6 @@ (t2/insert! :model/Session {:id session-id :key_hashed session-key-hashed, :user_id user-id, :created_at :%now}) (is (= "es_MX" (session-locale session-key))) - (testing "w/ X-Metabase-Locale header" (is (= "en_GB" (session-locale session-key :headers {"x-metabase-locale" "en-GB"})))))))))) @@ -483,7 +476,6 @@ :path "/" :expires "Sat, 1 Jan 2022 01:00:00 GMT"}}} (mw.session/reset-session-timeout* request response request-time))))) - (testing "with embedded sessions" (let [request {:cookies {request/metabase-embedded-session-cookie {:value "8df268ab-00c0-4b40-9413-d66b966b696a"} request/metabase-session-timeout-cookie {:value "alive"}} diff --git a/test/metabase/server/middleware/settings_cache_test.clj b/test/metabase/server/middleware/settings_cache_test.clj index 0837df07ebc0..5cb11e11e58a 100644 --- a/test/metabase/server/middleware/settings_cache_test.clj +++ b/test/metabase/server/middleware/settings_cache_test.clj @@ -43,7 +43,6 @@ (is (= (setting/cache-last-updated-at) (-> setting-cookie :value codec/form-decode)) "Cookie value is not most recent cache updated at timestamp"))) - (testing "And when that timestamp is outdated it restores the setting cache" (let [calls (atom 0)] ;; value in 2042 to simulate client has more recent settings diff --git a/test/metabase/server/middleware/ssl_test.clj b/test/metabase/server/middleware/ssl_test.clj index 4c6986fdca77..44ac273c1a1d 100644 --- a/test/metabase/server/middleware/ssl_test.clj +++ b/test/metabase/server/middleware/ssl_test.clj @@ -55,14 +55,12 @@ (let [response (handler (-> (ring.mock/request :get "/foo") (ring.mock/header :X-URL-Scheme "https")))] (is (= 200 (:status response)))) - (let [response (handler (-> (ring.mock/request :get "/foo") (ring.mock/header :X-Forwarded-SSL "on")))] (is (= 200 (:status response)))) (let [response (handler (-> (ring.mock/request :get "/foo") (ring.mock/header :Front-End-HTTPS "on")))] (is (= 200 (:status response)))) - (let [response (handler (-> (ring.mock/request :get "/foo") (ring.mock/header :Origin "https://foo")))] (is (= 200 (:status response))))))) diff --git a/test/metabase/server/routes/index_test.clj b/test/metabase/server/routes/index_test.clj index 6239b174a8c5..c4f10dfde4b5 100644 --- a/test/metabase/server/routes/index_test.clj +++ b/test/metabase/server/routes/index_test.clj @@ -65,7 +65,6 @@ json/decode (update "translations" select-keys [""]) (update-in ["translations" ""] select-keys ["Your database has been added!"]))))) - (testing "an invalid override causes a fallback to English" (is (= {"headers" {"language" "yy", "plural-forms" "nplurals=2; plural=(n != 1);"} "translations" {"" {"Metabase" {"msgid" "Metabase", "msgstr" ["Metabase"]}}}} diff --git a/test/metabase/server/settings_test.clj b/test/metabase/server/settings_test.clj index 8b80695f1529..612941e09f7e 100644 --- a/test/metabase/server/settings_test.clj +++ b/test/metabase/server/settings_test.clj @@ -20,7 +20,6 @@ (server.settings/redirect-all-requests-to-https! v) (is (true? (server.settings/redirect-all-requests-to-https))))) - (testing "\n`site-url` is not HTTPS" (mt/with-temporary-setting-values [site-url "http://example.com" redirect-all-requests-to-https false] diff --git a/test/metabase/server/streaming_response_test.clj b/test/metabase/server/streaming_response_test.clj index c02a123c6b89..8b2ed5e0a341 100644 --- a/test/metabase/server/streaming_response_test.clj +++ b/test/metabase/server/streaming_response_test.clj @@ -309,12 +309,10 @@ (testing "InterruptedException should not write to output stream" (streaming-response/write-error! os (InterruptedException. "interrupted") :api) (is (zero? (.size os)))) - (testing "EofException should not write to output stream" (.reset os) (streaming-response/write-error! os (org.eclipse.jetty.io.EofException. "eof") :api) (is (zero? (.size os)))) - (testing "Other exceptions should be formatted and written" (.reset os) (streaming-response/write-error! os (RuntimeException. "runtime error") :api) diff --git a/test/metabase/session/api_test.clj b/test/metabase/session/api_test.clj index 204caa3d57c3..63cb3f766228 100644 --- a/test/metabase/session/api_test.clj +++ b/test/metabase/session/api_test.clj @@ -458,17 +458,14 @@ (mt/client :post 400 "session/reset_password" {}))) (is (=? {:errors {:password "password is too common."}} (mt/client :post 400 "session/reset_password" {:token "anything"})))) - (testing "Test that malformed token returns 400" (is (=? {:errors {:password "Invalid reset token"}} (mt/client :post 400 "session/reset_password" {:token "not-found" :password "whateverUP12!!"})))) - (testing "Test that invalid token returns 400" (is (=? {:errors {:password "Invalid reset token"}} (mt/client :post 400 "session/reset_password" {:token "1_not-found" :password "whateverUP12!!"})))) - (testing "Test that an expired token doesn't work" (let [token (str (mt/user->id :rasta) "_" (random-uuid))] (t2/update! :model/User (mt/user->id :rasta) {:reset_token token, :reset_triggered 0}) @@ -483,11 +480,9 @@ (t2/update! :model/User (mt/user->id :rasta) {:reset_token token, :reset_triggered (dec (System/currentTimeMillis))}) (is (= {:valid true} (mt/client :get 200 "session/password_reset_token_valid", :token token))))) - (testing "Check than an made-up token returns false" (is (= {:valid false} (mt/client :get 200 "session/password_reset_token_valid", :token "ABCDEFG")))) - (testing "Check that an expired but valid token returns false" (let [token (str (mt/user->id :rasta) "_" (random-uuid))] (t2/update! :model/User (mt/user->id :rasta) {:reset_token token, :reset_triggered 0}) @@ -499,19 +494,15 @@ (testing "reset-token-ttl-hours-test is reset to default when not set" (mt/with-temp-env-var-value! [mb-reset-token-ttl-hours nil] (is (= 48 (setting/get-value-of-type :integer :reset-token-ttl-hours))))) - (testing "reset-token-ttl-hours-test is set to positive value" (mt/with-temp-env-var-value! [mb-reset-token-ttl-hours 36] (is (= 36 (setting/get-value-of-type :integer :reset-token-ttl-hours))))) - (testing "reset-token-ttl-hours-test is set to large positive value" (mt/with-temp-env-var-value! [mb-reset-token-ttl-hours (inc Integer/MAX_VALUE)] (is (= (inc Integer/MAX_VALUE) (setting/get-value-of-type :integer :reset-token-ttl-hours))))) - (testing "reset-token-ttl-hours-test is set to zero" (mt/with-temp-env-var-value! [mb-reset-token-ttl-hours 0] (is (= 0 (setting/get-value-of-type :integer :reset-token-ttl-hours))))) - (testing "reset-token-ttl-hours-test is set to negative value" (mt/with-temp-env-var-value! [mb-reset-token-ttl-hours -1] (is (= -1 (setting/get-value-of-type :integer :reset-token-ttl-hours))))))) @@ -521,23 +512,19 @@ (testing "Unauthenticated" (is (= (set (keys (setting/user-readable-values-map #{:public}))) (set (keys (mt/client :get 200 "session/properties")))))) - (testing "Authenticated normal user" (mt/with-test-user :lucky (is (= (set (keys (setting/user-readable-values-map #{:public :authenticated :admin-write-authed-read}))) (set (keys (mt/user-http-request :lucky :get 200 "session/properties"))))))) - (testing "Authenticated settings manager" (mt/with-test-user :lucky (with-redefs [metabase.settings.models.setting/has-advanced-setting-access? (constantly true)] (is (= (set (keys (setting/user-readable-values-map #{:public :authenticated :settings-manager :admin-write-authed-read}))) (set (keys (mt/user-http-request :lucky :get 200 "session/properties")))))))) - (testing "Authenticated super user" (mt/with-test-user :crowberto (is (= (set (keys (setting/user-readable-values-map #{:public :authenticated :settings-manager :admin-write-authed-read :admin}))) (set (keys (mt/user-http-request :crowberto :get 200 "session/properties"))))))) - (testing "Includes user-local settings" (defsetting test-session-api-setting "test setting" @@ -545,7 +532,6 @@ :user-local :only :type :string :default "FOO") - (mt/with-test-user :lucky (is (= "FOO" (-> (mt/user-http-request :crowberto :get 200 "session/properties") diff --git a/test/metabase/session/models/session_test.clj b/test/metabase/session/models/session_test.clj index da1b3c11f921..0f6526f157db 100644 --- a/test/metabase/session/models/session_test.clj +++ b/test/metabase/session/models/session_test.clj @@ -76,7 +76,6 @@ :device_id device :timestamp #t "2021-04-02T15:52:00-07:00[US/Pacific]" :device_description "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML like Gecko) Chrome/89.0.4389.86 Safari/537.36"}) - (is (malli= [:sequential {:min 1} [:map {:closed true} [:from ms/Email] @@ -99,13 +98,11 @@ ;; `format-human-readable` has slightly different output on different JVMs (u.date/format-human-readable #t "2021-04-02T15:52:00-07:00[US/Pacific]")]] (is (str/includes? message expected-str)))))) - (testing "don't send email on subsequent login from same device" (mt/reset-inbox!) (mt/with-temp [:model/LoginHistory _ {:user_id user-id, :device_id device}] (is (= {} @mt/inbox)))))))))))) - (testing "don't send email if the setting is disabled by setting MB_SEND_EMAIL_ON_FIRST_LOGIN_FROM_NEW_DEVICE=FALSE" (mt/with-temp [:model/User {user-id :id}] (mt/with-fake-inbox @@ -148,12 +145,10 @@ email :email} {:first_name "Ngoc" :last_name "Khuat"}] (is (contains? (new-login-email user-id email) "We've Noticed a New Metabase Login, Ngoc")))) - (testing "fallback to last name if user has no first_name" (mt/with-temp [:model/User {user-id :id email :email} {:first_name nil :last_name "Khuat"}] (is (contains? (new-login-email user-id email) "We've Noticed a New Metabase Login, Khuat")))) - (testing "Else Use email if both first_name and last_name are null" (mt/with-temp [:model/User {user-id :id email :email} {:first_name nil :last_name nil diff --git a/test/metabase/settings/models/setting/cache_test.clj b/test/metabase/settings/models/setting/cache_test.clj index 4f28a1b96240..444243cbe20b 100644 --- a/test/metabase/settings/models/setting/cache_test.clj +++ b/test/metabase/settings/models/setting/cache_test.clj @@ -49,10 +49,8 @@ (setting-test/clear-settings-last-updated-value-in-db!) (setting-test/toucan-name! "Bird Can") (is (string? (setting-test/settings-last-updated-value-in-db))) - (testing "...and is the value updated in the cache as well?" (is (string? (settings-last-updated-value-in-cache)))) - (testing "..and if I update it again, will the value be updated?" (let [first-value (setting-test/settings-last-updated-value-in-db)] ;; MySQL only has the resolution of one second on the timestamps here so we should wait that long to make sure @@ -68,13 +66,11 @@ (testing "If there is no cache, it should be considered out of date!" (clear-cache!) (#'setting.cache/cache-out-of-date?)) - (testing "But if I set a setting, it should cause the cache to be populated, and be up-to-date" (clear-cache!) (setting-test/toucan-name! "Reggae Toucan") (is (= false (#'setting.cache/cache-out-of-date?)))) - (testing "If another instance updates a Setting, `cache-out-of-date?` should return `true` based on DB comparisons..." (clear-cache!) (setting-test/toucan-name! "Reggae Toucan") diff --git a/test/metabase/settings/models/setting_test.clj b/test/metabase/settings/models/setting_test.clj index a826729800a7..def5b61b9ffb 100644 --- a/test/metabase/settings/models/setting_test.clj +++ b/test/metabase/settings/models/setting_test.clj @@ -183,7 +183,6 @@ (test-env-setting! nil) (is (= "ABCDEFG" (test-env-setting)))) - (testing "Test getting a default value -- if you clear the value of a Setting it should revert to returning the default value" (test-setting-2! nil) (is (= "[Default Value]" @@ -199,7 +198,6 @@ (test-setting-calculated-getter))) (is (true? (setting/user-facing-value :test-setting-calculated-getter)))) - (testing "`user-facing-value` will initialize pending values" (mt/discard-setting-changes [:test-setting-custom-init] (is (some? (setting/user-facing-value :test-setting-custom-init)))))) @@ -318,14 +316,12 @@ (db-fetch-setting :test-setting-1))) (is (= "For realz" (db-fetch-setting :test-setting-2)))) - (testing "unregistered settings should be silently skipped" (setting/set-many! {:test-setting-1 "known value" :totally-fake-setting "unknown value"}) (is (= "known value" (db-fetch-setting :test-setting-1))) (is (not (setting/registered? :totally-fake-setting)))) - (testing "if one change fails, the entire set of changes should be reverted" (mt/with-temporary-setting-values [test-setting-1 "123" test-setting-2 "123"] @@ -363,7 +359,6 @@ (setting/get :test-setting-1))) (is (= false (setting-exists-in-db? :test-setting-1)))) - (testing "w/ default value" (test-setting-2! "COOL") (is (= "COOL" @@ -398,31 +393,24 @@ (testing "user-facing info w/ no db value, no env var value, no default value" (is (= {:value nil, :is_env_setting false, :env_name "MB_TEST_SETTING_1", :default nil} (user-facing-info-with-db-and-env-var-values! :test-setting-1 nil nil)))) - (testing "user-facing info w/ no db value, no env var value, default value" (is (= {:value nil, :is_env_setting false, :env_name "MB_TEST_SETTING_2", :default "[Default Value]"} (user-facing-info-with-db-and-env-var-values! :test-setting-2 nil nil)))) - (testing "user-facing info w/ no db value, env var value, no default value -- shouldn't leak env var value" (is (= {:value nil, :is_env_setting true, :env_name "MB_TEST_SETTING_1", :default "Using value of env var $MB_TEST_SETTING_1"} (user-facing-info-with-db-and-env-var-values! :test-setting-1 nil "TOUCANS")))) - (testing "user-facing info w/ no db value, env var value, default value" (is (= {:value nil, :is_env_setting true, :env_name "MB_TEST_SETTING_2", :default "Using value of env var $MB_TEST_SETTING_2"} (user-facing-info-with-db-and-env-var-values! :test-setting-2 nil "TOUCANS")))) - (testing "user-facing info w/ db value, no env var value, no default value" (is (= {:value "WOW", :is_env_setting false, :env_name "MB_TEST_SETTING_1", :default nil} (user-facing-info-with-db-and-env-var-values! :test-setting-1 "WOW" nil)))) - (testing "user-facing info w/ db value, no env var value, default value" (is (= {:value "WOW", :is_env_setting false, :env_name "MB_TEST_SETTING_2", :default "[Default Value]"} (user-facing-info-with-db-and-env-var-values! :test-setting-2 "WOW" nil)))) - (testing "user-facing info w/ db value, env var value, no default value -- the env var should take precedence over the db value, but should be obfuscated" (is (= {:value nil, :is_env_setting true, :env_name "MB_TEST_SETTING_1", :default "Using value of env var $MB_TEST_SETTING_1"} (user-facing-info-with-db-and-env-var-values! :test-setting-1 "WOW" "ENV VAR")))) - (testing "user-facing info w/ db value, env var value, default value -- env var should take precedence over default, but should be obfuscated" (is (= {:value nil, :is_env_setting true, :env_name "MB_TEST_SETTING_2", :default "Using value of env var $MB_TEST_SETTING_2"} (user-facing-info-with-db-and-env-var-values! :test-setting-2 "WOW" "ENV VAR"))))) @@ -442,7 +430,6 @@ (when (re-find #"^test-setting-2$" (name (:key setting))) setting)) (setting/writable-settings)))) - (testing "with a custom getter" (test-setting-1! nil) (test-setting-2! "TOUCANS") @@ -456,7 +443,6 @@ (when (re-find #"^test-setting-2$" (name (:key setting))) setting)) (setting/writable-settings :getter (comp count (partial setting/get-value-of-type :string))))))) - ;; TODO -- probably don't need both this test and the "TOUCANS" test above, we should combine them (testing "test settings" (test-setting-1! nil) @@ -535,18 +521,15 @@ :default "Using value of env var $MB_TEST_BOOLEAN_SETTING"}] (is (= expected (user-facing-info-with-db-and-env-var-values! :test-boolean-setting nil "true"))) - (testing "env var values should be case-insensitive" (is (= expected (user-facing-info-with-db-and-env-var-values! :test-boolean-setting nil "TRUE")))))) - (testing "if value isn't true / false" (testing "getter should throw exception" (is (thrown-with-msg? Exception #"Invalid value for string: must be either \"true\" or \"false\" \(case-insensitive\)" (test-boolean-setting! "X")))) - (testing "user-facing info should just return `nil` instead of failing entirely" (is (= {:value nil :is_env_setting true @@ -560,7 +543,6 @@ (test-boolean-setting! "FALSE"))) (is (= false (test-boolean-setting))) - (testing "... or a boolean" (is (= "false" (test-boolean-setting! false))) @@ -591,7 +573,6 @@ (testing "should be able to fetch a simple CSV setting" (is (= ["A" "B" "C"] (fetch-csv-setting-value! "A,B,C")))) - (testing "should also work if there are quoted values that include commas in them" (is (= ["A" "B" "C1,C2" "ddd"] (fetch-csv-setting-value! "A,B,\"C1,C2\",ddd"))))) @@ -605,23 +586,18 @@ (testing "should be able to correctly set a simple CSV setting" (is (= {:db-value "A,B,C", :parsed-value ["A" "B" "C"]} (set-and-fetch-csv-setting-value! ["A" "B" "C"])))) - (testing "should be a able to set a CSV setting with a value that includes commas" (is (= {:db-value "A,B,C,\"D1,D2\"", :parsed-value ["A" "B" "C" "D1,D2"]} (set-and-fetch-csv-setting-value! ["A" "B" "C" "D1,D2"])))) - (testing "should be able to set a CSV setting with a value that includes spaces" (is (= {:db-value "A,B,C, D ", :parsed-value ["A" "B" "C" " D "]} (set-and-fetch-csv-setting-value! ["A" "B" "C" " D "])))) - (testing "should be a able to set a CSV setting when the string is already CSV-encoded" (is (= {:db-value "A,B,C", :parsed-value ["A" "B" "C"]} (set-and-fetch-csv-setting-value! "A,B,C")))) - (testing "should be able to set nil CSV setting" (is (= {:db-value nil, :parsed-value nil} (set-and-fetch-csv-setting-value! nil)))) - (testing "default values for CSV settings should work" (test-csv-setting-with-default! nil) (is (= ["A" "B" "C"] @@ -646,11 +622,9 @@ (encryption-test/with-secret-key "ABCDEFGH12345678" (toucan-name! "Sad Can") (is (u/base64-string? (actual-value-in-db :toucan-name))) - (testing "make sure it can be decrypted as well..." (is (= "Sad Can" (toucan-name))))) - (testing "But if encryption is not enabled, of course Settings shouldn't get saved as encrypted." (encryption-test/with-secret-key nil (toucan-name! "Sad Can") @@ -684,7 +658,6 @@ (deftest timestamp-settings-test (test-assert-setting-has-tag #'test-timestamp-setting 'java.time.temporal.Temporal) - (testing "make sure we can set & fetch the value and that it gets serialized/deserialized correctly" (test-timestamp-setting! #t "2018-07-11T09:32:00.000Z") (is (= #t "2018-07-11T09:32:00.000Z" @@ -714,14 +687,12 @@ (uncached-setting! "ABCDEF") (is (= "ABCDEF" (actual-value-in-db "uncached-setting")))) - (testing "make sure that fetching the Setting always fetches the latest value from the DB" (uncached-setting! "ABCDEF") (t2/update! :model/Setting {:key "uncached-setting"} {:value "123456"}) (is (= "123456" (uncached-setting)))) - (testing "make sure that updating the setting doesn't update the last-updated timestamp in the cache $$" (clear-settings-last-updated-value-in-db!) (uncached-setting! "abcdef") @@ -758,7 +729,6 @@ (test-sensitive-setting! "ABC123") (is (= "**********23" (setting/user-facing-value "test-sensitive-setting")))) - (testing "Attempting to set a sensitive setting to an obfuscated value should be ignored -- it was probably done accidentally" (test-sensitive-setting! "123456") (test-sensitive-setting! "**********56") @@ -961,7 +931,6 @@ clojure.lang.ExceptionInfo #"Site-wide values are not allowed for Setting :test-database-local-only-setting" (test-database-local-only-setting! 2)))) - (testing "Default values should be allowed for Database-local-only Settings" (binding [setting/*database-local-values* {}] (is (= "DEFAULT" @@ -1021,10 +990,8 @@ (is (= "DEF" (test-user-local-only-setting)))) (mt/with-current-user (mt/user->id :rasta) (is (= "ABC" (test-user-local-only-setting))))) - (testing "A user-local-only setting cannot have a site-wide value" (is (thrown-with-msg? Throwable #"Site-wide values are not allowed" (test-user-local-only-setting! "ABC")))) - (testing "Reading and writing a user-local-allowed setting in the context of a user uses the user-local value" ;; TODO: mt/with-temporary-setting-values only affects site-wide value, we should figure out whether it should also ;; affect user-local settings. @@ -1044,7 +1011,6 @@ (is (= "DEF" (test-user-local-allowed-setting)))) (mt/with-current-user (mt/user->id :rasta) (is (= "ABC" (test-user-local-allowed-setting)))))) - (testing "Reading and writing a user-local-never setting in the context of a user uses the site-wide value" (mt/with-current-user (mt/user->id :rasta) (test-user-local-never-setting! "ABC") @@ -1055,7 +1021,6 @@ (mt/with-current-user (mt/user->id :rasta) (is (= "DEF" (test-user-local-never-setting)))) (is (= "DEF" (test-user-local-never-setting)))) - (testing "A setting cannot be defined to allow both user-local and database-local values" (is (thrown-with-msg? Throwable @@ -1083,7 +1048,6 @@ (deferred-tru "test Setting") :driver-feature :actions :encryption :when-encryption-key-set)))) - (testing "Having :database-local :allowed is not enough to use :driver-feature" (is (thrown-with-msg? Throwable @@ -1093,7 +1057,6 @@ :database-local :allowed :driver-feature :actions/data-editing :encryption :when-encryption-key-set)))) - (testing "Having :database-local :only is OK" (is (some? test-driver-feature-only-setting)))) @@ -1104,16 +1067,13 @@ setting-without-driver-feature :test-database-local-allowed-setting driver-supports-everything? (constantly true) driver-supports-nothing? (constantly false)] - (testing "should succeed when driver supports required feature" (is (nil? (setting/validate-settable-for-db! setting-with-driver-feature database driver-supports-everything?)))) - (testing "should throw when driver does not support required feature" (is (thrown-with-msg? ExceptionInfo #"Setting test-driver-feature-only-setting requires driver feature :actions, but the database does not support it" (setting/validate-settable-for-db! setting-with-driver-feature database driver-supports-nothing?)))) - (testing "should succeed for settings without driver-feature requirement" (is (nil? (setting/validate-settable-for-db! setting-without-driver-feature database driver-supports-nothing?))))))) @@ -1134,7 +1094,6 @@ (deferred-tru "test Setting") :enabled-for-db? (constantly true) :encryption :when-encryption-key-set)))) - (testing "Having :database-local :allowed is not enough to use :enabled-for-db?" (is (thrown-with-msg? ExceptionInfo @@ -1144,7 +1103,6 @@ :database-local :allowed :enabled-for-db? (constantly true) :encryption :when-encryption-key-set)))) - (testing "A setting with :enabled-for-db? and :database-local :only should be valid" (is (some? test-enabled-for-db-setting)))) @@ -1152,12 +1110,10 @@ (testing "validate-settable-for-db! validates database-specific enablement" (let [regular-database {:id 1 :engine :h2} routed-database {:id 2 :engine :h2 :router_database_id 3}] - (testing "should succeed when database passes enabled-for-db? predicate" (is (nil? (setting/validate-settable-for-db! :test-enabled-for-db-setting regular-database (constantly true))))) - (testing "should throw when database fails enabled-for-db? predicate" (is (thrown-with-msg? ExceptionInfo @@ -1165,7 +1121,6 @@ (setting/validate-settable-for-db! :test-enabled-for-db-setting routed-database (constantly true))))) - (testing "should succeed for settings without enabled-for-db? requirement" (is (nil? (setting/validate-settable-for-db! :test-database-local-allowed-setting routed-database @@ -1192,22 +1147,18 @@ db-with-error {:id 2 :has-error true :settings settings} db-with-both {:id 3 :has-error true :has-warning true :settings settings} every-feature (constantly true)] - (testing "Settings with only warning reasons should not be disabled" (with-database db-with-warning (testing "configured value is still returned" (is (= "custom-value" (test-warn-vs-error-setting)))))) - (testing "Settings with error reasons should be disabled" (with-database db-with-error (testing "configured value is not returned" (is (= "default-value" (test-warn-vs-error-setting)))))) - (testing "Settings with both warning and error reasons should be disabled" (with-database db-with-both (testing "configured value is not returned" (is (= "default-value" (test-warn-vs-error-setting)))))) - (testing "validate-settable-for-db! should only throw for error reasons" (testing "should not throw for warnings" (is (nil? (setting/validate-settable-for-db! :test-warn-vs-error-setting db-with-warning every-feature)))) @@ -1253,14 +1204,12 @@ (mt/with-premium-features #{:test-feature} (test-feature-setting! "custom") (is (= "custom" (test-feature-setting)))) - (mt/with-premium-features #{} (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Setting test-feature-setting is not enabled because feature :test-feature is not available" (test-feature-setting! "custom 2"))) (is (= "setting-default" (test-feature-setting))))) - (testing "A setting cannot have both the :enabled? and :feature options at once" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -1487,12 +1436,10 @@ :model "Setting" :details {:key "test-setting-1"}} (last-audit-event-fn)))) - (testing "Auditing can be disabled with `:audit :never`" (test-setting-audit-never! "DON'T AUDIT") (is (not= "test-setting-audit-never" (-> (last-audit-event-fn) :details :key)))) - (testing "Raw values (as stored in the DB) can be logged with `:audit :raw-value`" (mt/with-temporary-setting-values [test-setting-audit-raw-value 99] (test-setting-audit-raw-value! 100) @@ -1503,7 +1450,6 @@ :previous-value "99" :new-value "100"}} (last-audit-event-fn))))) - (testing "Values returned from the setting's getter can be logged with `:audit :getter`" (mt/with-temporary-setting-values [test-setting-audit-getter "PREVIOUS VALUE"] (test-setting-audit-getter! "NEW RAW VALUE") @@ -1514,7 +1460,6 @@ :previous-value "GETTER VALUE" :new-value "GETTER VALUE"}} (last-audit-event-fn))))) - (testing "Sensitive settings have their values obfuscated automatically" (mt/with-temporary-setting-values [test-sensitive-setting-audit nil] (test-sensitive-setting-audit! "old password") @@ -1541,7 +1486,6 @@ (test-user-local-only-setting! "DON'T AUDIT")) (is (not= "test-user-local-only-setting" (-> (mt/latest-audit-log-entry :setting-update) :details :key)))) - (testing "User-local settings can be audited" (mt/with-test-user :rasta (mt/with-temporary-setting-values [test-user-local-only-audited-setting nil] @@ -1571,7 +1515,6 @@ (testing "The :export? property is exposed" (is (#'setting/export? :exported-setting)) (is (not (#'setting/export? :non-exported-setting)))) - (testing "By default settings are not exported" (is (not (#'setting/export? :test-setting-1))))) @@ -1623,7 +1566,7 @@ (let [ex (get-parse-exception :json "[1, 2,")] (assert-parser-exception! :json ex - ;; TODO it would be safe to expose the raw Jackson exception here, we could improve redaction logic + ;; TODO it would be safe to expose the raw Jackson exception here, we could improve redaction logic #_(str "Unexpected end-of-input within/between Array entries\n" " at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 7]") "Error of type class com.fasterxml.jackson.core.JsonParseException thrown while parsing a setting")))) @@ -1664,7 +1607,7 @@ (let [ex (get-parse-exception :csv "\"1\"$ekr3t")] (assert-parser-exception! :csv ex - ;; we don't expose the raw exception here, as it would give away the first character of the secret + ;; we don't expose the raw exception here, as it would give away the first character of the secret #_"CSV error (unexpected character: $)" "Error of type class java.lang.Exception thrown while parsing a setting")))) diff --git a/test/metabase/settings_rest/api_test.clj b/test/metabase/settings_rest/api_test.clj index 4c2e118b1bbf..ae5bbcaf03df 100644 --- a/test/metabase/settings_rest/api_test.clj +++ b/test/metabase/settings_rest/api_test.clj @@ -92,11 +92,9 @@ :description "Test setting - this only shows up in dev (2)" :default "[Default Value]"}] (fetch-test-settings [:test-setting-1 :test-setting-2 :test-setting-3])))) - (testing "Check that fetching Settings does not return settings marked as :include-in-list?=false" (is (empty? (fetch-test-settings [:test-setting-that-is-not-included-when-listing-in-api])))) - (testing "Check that non-admin setting managers can fetch Settings with `:visibility :settings-manager`" (test-settings-manager-visibility! nil) (with-mocked-settings-manager-access! @@ -107,7 +105,6 @@ :description "Setting to test the `:settings-manager` visibility level. This only shows up in dev.", :default nil}] (fetch-test-settings :rasta [:test-setting-1 :test-settings-manager-visibility]))))) - (testing "Check that authenticated users can read Settings with `:visibility :admin-write-authed-read` via session/properties" (test-admin-write-authed-read-visibility! "VALUE") (is (= "VALUE" @@ -117,22 +114,18 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "setting")))) (test-admin-write-authed-read-visibility! nil)) - (testing "Check that non-admins are denied access" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "setting"))))) - (testing "GET /api/setting/:key" (testing "Test that admins can fetch a single Setting" (models.setting-test/test-setting-2! "OK!") (is (= "OK!" (fetch-setting :test-setting-2 200)))) - (testing "Test that non-admin setting managers can fetch a single Setting if it has `:visibility :settings-manager`." (test-settings-manager-visibility! "OK!") (with-mocked-settings-manager-access! (is (= "OK!" (fetch-setting :test-settings-manager-visibility 200))))) - (testing "Check that non-superusers cannot fetch a single Setting if it is not user-local" (is (= "You don't have permissions to do that." (fetch-setting :rasta :test-setting-2 403)))) @@ -140,10 +133,8 @@ ;; n.b. the api will return nil if a setting is its default value. (test-api-setting-double! 3.14) (is (= 3.14 (fetch-setting :test-api-setting-double 200))) - (test-api-setting-boolean! true) (is (true? (fetch-setting :test-api-setting-boolean 200))) - (test-api-setting-integer! 42) (is (= 42 (fetch-setting :test-api-setting-integer 200)))))) @@ -181,22 +172,17 @@ (is (= "NICE!" (models.setting-test/test-setting-1)) "Updated setting should be visible from setting getter") - (is (= "NICE!" (fetch-setting :test-setting-1 200)) "Updated setting should be visible from API endpoint") - (testing "Check that non-admin setting managers can only update Settings with `:visibility :settings-manager`." (with-mocked-settings-manager-access! (mt/user-http-request :rasta :put 204 "setting/test-settings-manager-visibility" {:value "NICE!"}) (is (= "NICE!" (fetch-setting :test-settings-manager-visibility 200))) - (mt/user-http-request :rasta :put 403 "setting/test-setting-1" {:value "Not nice :("}))) - (testing "Check non-superuser can't set a Setting that is not user-local" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "setting/test-setting-1" {:value "NICE!"})))) - (testing "Check that :admin-write-authed-read settings are writable by admins but not non-admins" (mt/user-http-request :crowberto :put 204 "setting/test-admin-write-authed-read-visibility" {:value "ADMIN_SET"}) (is (= "ADMIN_SET" @@ -207,7 +193,6 @@ (test-admin-write-authed-read-visibility)) "Value should be unchanged after non-admin write attempt") (test-admin-write-authed-read-visibility! nil)) - (testing "Check that a generic 403 error is returned if a non-superuser tries to set a Setting that doesn't exist" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "setting/bad-setting" {:value "NICE!"})))))) @@ -218,7 +203,6 @@ (models.setting-test/test-sensitive-setting! "ABCDEF") (is (= "**********EF" (fetch-setting :test-sensitive-setting 200)))) - (testing "GET /api/setting" (models.setting-test/test-sensitive-setting! "GHIJKLM") (is (= {:key "test-sensitive-setting" @@ -238,7 +222,6 @@ (mt/user-http-request :crowberto :put 204 "setting/test-sensitive-setting" {:value "123456"}) (is (= "123456" (models.setting-test/test-sensitive-setting)))) - (testing "Attempts to set the Setting to an obfuscated value should be ignored" (testing "PUT /api/setting/:name" (models.setting-test/test-sensitive-setting! "123456") @@ -246,7 +229,6 @@ (mt/user-http-request :crowberto :put 204 "setting/test-sensitive-setting" {:value "**********56"}))) (is (= "123456" (models.setting-test/test-sensitive-setting)))) - (testing "PUT /api/setting" (models.setting-test/test-sensitive-setting! "123456") (is (= nil @@ -265,7 +247,6 @@ (models.setting-test/test-setting-1))) (is (= "DEF" (models.setting-test/test-setting-2)))) - (testing "non-admin setting managers should only be able to update multiple settings at once if they have `:visibility :settings-manager`" (with-mocked-settings-manager-access! (is (= nil @@ -278,7 +259,6 @@ (test-settings-manager-visibility))) (is (= "ABC" (models.setting-test/test-setting-1))))) - (testing "non-admin should not be able to update multiple settings at once if any of them are not user-local" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "setting" {:test-setting-1 "GHI", :test-setting-2 "JKL"}))) @@ -331,7 +311,6 @@ :default nil}] (fetch-user-local-test-settings :crowberto))) (clear-user-local-values))) - (testing "GET /api/setting/:key" (testing "should return the user-local value of a user-local setting" (set-initial-user-local-values) @@ -339,13 +318,11 @@ (mt/user-http-request :crowberto :get 200 "setting/test-user-local-only-setting"))) (is (= "ABC" (mt/user-http-request :crowberto :get 200 "setting/test-user-local-allowed-setting"))) - (is (= "DEF" (mt/user-http-request :rasta :get 200 "setting/test-user-local-only-setting"))) (is (= "DEF" (mt/user-http-request :rasta :get 200 "setting/test-user-local-allowed-setting"))) (clear-user-local-values))) - (testing "PUT /api/setting/:key" (testing "should update the user-local value of a user-local setting" (set-initial-user-local-values) @@ -355,7 +332,6 @@ (mt/user-http-request :crowberto :put 204 "setting/test-user-local-allowed-setting" {:value "JKL"}) (is (= "JKL" (mt/user-http-request :crowberto :get 200 "setting/test-user-local-allowed-setting"))) - (mt/user-http-request :rasta :put 204 "setting/test-user-local-only-setting" {:value "MNO"}) (is (= "MNO" (mt/user-http-request :rasta :get 200 "setting/test-user-local-only-setting"))) @@ -363,7 +339,6 @@ (is (= "PQR" (mt/user-http-request :rasta :get 200 "setting/test-user-local-allowed-setting"))) (clear-user-local-values))) - (testing "PUT /api/setting" (testing "can updated multiple user-local settings at once" (set-initial-user-local-values) @@ -373,7 +348,6 @@ (mt/user-http-request :crowberto :get 200 "setting/test-user-local-only-setting"))) (is (= "JKL" (mt/user-http-request :crowberto :get 200 "setting/test-user-local-allowed-setting"))) - (mt/user-http-request :rasta :put 204 "setting" {:test-user-local-only-setting "MNO" :test-user-local-allowed-setting "PQR"}) (is (= "MNO" @@ -381,7 +355,6 @@ (is (= "PQR" (mt/user-http-request :rasta :get 200 "setting/test-user-local-allowed-setting"))) (clear-user-local-values)) - (testing "if a non-admin tries to set multiple settings and any aren't user-local, none are updated" (set-initial-user-local-values) (models.setting-test/test-setting-1! "ABC") @@ -400,13 +373,11 @@ (is (= "ABC" (mt/user-http-request :crowberto :get 200 "setting/test_setting_1"))) (is (= (mt/user-http-request :crowberto :get 200 "setting/test-setting-1") (mt/user-http-request :crowberto :get 200 "setting/test_setting_1")))) - (testing "PUT /api/setting/:key" (mt/user-http-request :crowberto :put 204 "setting/test_setting_1" {:value "DEF"}) (is (= "DEF" (mt/user-http-request :crowberto :get 200 "setting/test_setting_1"))) (is (= (mt/user-http-request :crowberto :get 200 "setting/test-setting-1") (mt/user-http-request :crowberto :get 200 "setting/test_setting_1")))) - (testing "PUT /api/setting" (mt/user-http-request :crowberto :put 204 "setting" {:test_setting_1 "GHI", :test_setting_2 "JKL"}) (is (= "GHI" (mt/user-http-request :crowberto :get 200 "setting/test_setting_1"))) diff --git a/test/metabase/setup/core_test.clj b/test/metabase/setup/core_test.clj index de28fd2344ca..a30712da04fb 100644 --- a/test/metabase/setup/core_test.clj +++ b/test/metabase/setup/core_test.clj @@ -116,7 +116,6 @@ (is (= "unencrypted" (t2/select-one-fn :value "setting" :key "encryption-check"))) (is (not (encryption/possibly-encrypted-string? (t2/select-one-fn :details "metabase_database")))) (is (= 1 (t2/count :model/QueryCache))) - (testing "Adding encryption encrypts database on restart" (encryption-test/with-secret-key "key1" (reset! (:status mdb.connection/*application-db*) ::setup-finished) diff --git a/test/metabase/slackbot/api_test.clj b/test/metabase/slackbot/api_test.clj index c81e9792c4e0..23944ac86ef7 100644 --- a/test/metabase/slackbot/api_test.clj +++ b/test/metabase/slackbot/api_test.clj @@ -58,7 +58,6 @@ (tu/slack-request-options body) body)] (is (= "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P" response)))) - (testing "handles 'unknown' events with ack message" (let [body {:type "event_callback" :event {:type "team_rename" @@ -67,7 +66,6 @@ (tu/slack-request-options body) body)] (is (= "ok" response)))) - (testing "handles message.im events" (let [body (-> tu/base-dm-event (assoc-in [:event :channel] "D123") @@ -76,7 +74,6 @@ (tu/slack-request-options body) body)] (is (= "ok" response)))) - (testing "rejects requests without valid signature" (is (= "Slack request signature is not valid." (mt/client :post 401 "metabot/slack/events" @@ -342,29 +339,24 @@ (tu/slack-request-options event-body) event-body)] (is (= "ok" response)) - (u/poll {:thunk #(and (>= (count @stop-stream-calls) 1) (>= (count @image-calls) 2)) :done? true? :timeout-ms 5000}) - (testing "streaming message flow works" (is (= 1 (count @stream-calls))) (is (= "C456" (:channel (first @stream-calls)))) (is (some #(= mock-ai-text %) @append-text-calls)) (is (= 1 (count @stop-stream-calls)))) - (testing "output generation called for each static_viz" (is (= 2 (count @generate-card-output-calls))) (is (= #{101 202} (set (map :card-id @generate-card-output-calls))))) - (testing "rendered PNGs are uploaded to Slack" (is (= 2 (count @image-calls))) (is (= #{"card_101.png" "card_202.png"} (set (map :filename @image-calls)))) (is (every? #(= (vec fake-png-bytes) (vec (:image-bytes %))) @image-calls))) - (testing "stop-stream includes both image blocks and feedback controls" (let [blocks (:blocks (first @stop-stream-calls))] (is (= ["section" "image" "section" "image" "context_actions"] @@ -440,7 +432,6 @@ :metadata {:signing_secret_version 0}}] (is (= active-slack-user-id (#'slackbot/slack-id->user-id slack-id))))) - (testing "returns user ID for active user with sso_source 'google'" (mt/with-temp [:model/AuthIdentity _ {:user_id active-google-user-id :provider "slack-connect" @@ -448,20 +439,17 @@ :metadata {:signing_secret_version 0}}] (is (= active-google-user-id (#'slackbot/slack-id->user-id slack-id))))) - (testing "returns nil for inactive user with sso_source 'slack'" (mt/with-temp [:model/AuthIdentity _ {:user_id inactive-slack-user-id :provider "slack-connect" :provider_id slack-id :metadata {:signing_secret_version 0}}] (is (nil? (#'slackbot/slack-id->user-id slack-id))))) - (testing "returns nil for active user with different provider" (mt/with-temp [:model/AuthIdentity _ {:user_id active-google-user-id :provider "google" :provider_id slack-id}] (is (nil? (#'slackbot/slack-id->user-id slack-id))))) - (testing "returns nil when no AuthIdentity exists" (is (nil? (#'slackbot/slack-id->user-id slack-id))))))))) @@ -477,7 +465,6 @@ :provider_id slack-id :metadata {:signing_secret_version 1}}] (is (= user-id (#'slackbot/slack-id->user-id slack-id)))))) - (testing "identity with old version is rejected after rotation" (mt/with-temporary-setting-values [server.settings/slack-connect-signing-secret-version 2] (mt/with-temp [:model/AuthIdentity _ {:user_id user-id @@ -485,14 +472,12 @@ :provider_id slack-id :metadata {:signing_secret_version 1}}] (is (nil? (#'slackbot/slack-id->user-id slack-id)))))) - (testing "legacy identity with no version is accepted before any rotation" (mt/with-temporary-setting-values [server.settings/slack-connect-signing-secret-version 0] (mt/with-temp [:model/AuthIdentity _ {:user_id user-id :provider "slack-connect" :provider_id slack-id}] (is (= user-id (#'slackbot/slack-id->user-id slack-id)))))) - (testing "legacy identity with no version is rejected after rotation" (mt/with-temporary-setting-values [server.settings/slack-connect-signing-secret-version 1] (mt/with-temp [:model/AuthIdentity _ {:user_id user-id @@ -577,10 +562,8 @@ (testing "authorize-delete-request" (testing "returns :ignored when channel-id is nil" (is (= :ignored (:status (#'slackbot/authorize-delete-request "U123" nil "ts123"))))) - (testing "returns :ignored when message-ts is nil" (is (= :ignored (:status (#'slackbot/authorize-delete-request "U123" "C123" nil))))) - (testing "returns :ignored for unknown Slack user" (with-redefs [slackbot/slack-id->user-id (constantly nil)] (is (= {:status :ignored @@ -589,17 +572,14 @@ :channel-id "C123" :message-ts "ts123"} (#'slackbot/authorize-delete-request "U-UNKNOWN" "C123" "ts123"))))) - (testing "returns :ignored when response is not tracked in the DB" (with-redefs [slackbot/slack-id->user-id (constantly (mt/user->id :rasta)) slackbot.persistence/response-owner-user-id (constantly nil)] (is (= :ignored (:status (#'slackbot/authorize-delete-request "U123" "C123" "ts123")))))) - (testing "returns :ignored when the requester is not the response owner" (with-redefs [slackbot/slack-id->user-id (constantly (mt/user->id :rasta)) slackbot.persistence/response-owner-user-id (constantly (mt/user->id :crowberto))] (is (= :ignored (:status (#'slackbot/authorize-delete-request "U123" "C123" "ts123")))))) - (testing "returns :authorized when the requester owns the response" (let [user-id (mt/user->id :rasta)] (with-redefs [slackbot/slack-id->user-id (constantly user-id) @@ -643,7 +623,6 @@ (is (= channel-id (:channel (first @update-calls)))) (is (= message-ts (:ts (first @update-calls)))) (is (str/includes? (:text (first @update-calls)) "removed")))))))))) - (testing "reaction_added with a non-delete emoji is ignored" (tu/with-slackbot-setup (let [event-body {:type "event_callback" @@ -662,7 +641,6 @@ event-body) (Thread/sleep 200) (is (= 0 (count @update-calls)) "non-delete emoji should produce no update")))))) - (testing "reaction_added with delete emoji from non-owner is ignored" (tu/with-slackbot-setup (let [event-body {:type "event_callback" @@ -697,21 +675,18 @@ slack-connect-client-secret nil metabot-slack-signing-secret nil] (is (= {:ok true} (mt/user-http-request :crowberto :put 200 "metabot/slack/settings" creds)))))) - (testing "clear all credentials" (mt/with-temporary-setting-values [sso-settings/slack-connect-enabled true] (mt/with-temporary-raw-setting-values [slack-connect-client-id "x" slack-connect-client-secret "x" metabot-slack-signing-secret "x"] (is (= {:ok true} (mt/user-http-request :crowberto :put 200 "metabot/slack/settings" clear)))))) - (testing "partial credentials returns 400" (doseq [partial [(assoc creds :slack-connect-client-id nil) (assoc creds :slack-connect-client-secret nil) (assoc creds :metabot-slack-signing-secret nil)]] (is (= "Must provide client id, client secret and signing secret together." (mt/user-http-request :crowberto :put 400 "metabot/slack/settings" partial))))) - (testing "non-admin returns 403" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "metabot/slack/settings" creds)))))) @@ -730,7 +705,6 @@ :metabot-slack-signing-secret "same-signing-secret"}))) (is (= 7 (server.settings/slack-connect-signing-secret-version)))))) - (testing "changing the signing secret increments the version" (mt/with-temporary-setting-values [sso-settings/slack-connect-enabled true server.settings/slack-connect-signing-secret-version 7] @@ -751,7 +725,6 @@ (is (= "metabot_feedback_modal" (:callback_id view))) (is (= 1 (count (:blocks view)))) (is (= "freeform_feedback" (:block_id (first (:blocks view))))))) - (testing "negative feedback modal has issue type dropdown and freeform input" (let [view (#'slackbot/feedback-modal-view false {:conversation_id "c1"})] (is (= 2 (count (:blocks view)))) @@ -886,7 +859,6 @@ (is (= "not-factual" (:issue_type row))) (is (= "The answer was wrong" (:freeform_feedback row)))))) (finally (tear-down-slackbot-feedback! conv-id))))) - (testing "positive feedback with only freeform text submits" (let [{:keys [conv-id external-id message-id]} (setup-slackbot-feedback! rasta-id)] (try @@ -899,7 +871,6 @@ @result (is (some? (t2/select-one :model/MetabotFeedback :message_id message-id :user_id rasta-id)))) (finally (tear-down-slackbot-feedback! conv-id))))) - (testing "positive feedback with nil freeform is stored as nil locally" (let [{:keys [conv-id external-id message-id]} (setup-slackbot-feedback! rasta-id)] (try @@ -914,7 +885,6 @@ (is (some? row)) (is (nil? (:freeform_feedback row))))) (finally (tear-down-slackbot-feedback! conv-id))))) - (testing "negative feedback with only issue type submits" (let [{:keys [conv-id external-id message-id]} (setup-slackbot-feedback! rasta-id)] (try diff --git a/test/metabase/slackbot/config_test.clj b/test/metabase/slackbot/config_test.clj index 0aa44b6f4667..a85d17630ee7 100644 --- a/test/metabase/slackbot/config_test.clj +++ b/test/metabase/slackbot/config_test.clj @@ -19,27 +19,21 @@ (tu/slack-request-options request-body) request-body)] (testing "succeeds when all settings are configured" (is (= "ok" (post-events 200)))) - (testing "returns 503 when client-id missing" (mt/with-temporary-setting-values [sso-settings/slack-connect-client-id nil] (is (= "Slack integration is not fully configured." (post-events 503))))) - (testing "returns 503 when client-secret missing" (mt/with-temporary-setting-values [sso-settings/slack-connect-client-secret nil] (is (= "Slack integration is not fully configured." (post-events 503))))) - (testing "returns 503 when bot-token missing" (mt/with-temporary-setting-values [channel.settings/slack-app-token nil] (is (= "Slack integration is not fully configured." (post-events 503))))) - (testing "returns 503 when encryption disabled" (with-redefs [encryption/default-secret-key nil] (is (= "Slack integration is not fully configured." (post-events 503))))) - (testing "returns 503 when site-url missing" (mt/with-temporary-setting-values [site-url nil] (is (= "Slack integration is not fully configured." (post-events 503))))) - (testing "returns 503 when signing-secret missing (can't sign request)" (mt/with-temporary-raw-setting-values [metabot-slack-signing-secret nil] (is (= "Slack integration is not fully configured." diff --git a/test/metabase/slackbot/persistence_test.clj b/test/metabase/slackbot/persistence_test.clj index e72cf1b60d00..6c8dd990081c 100644 --- a/test/metabase/slackbot/persistence_test.clj +++ b/test/metabase/slackbot/persistence_test.clj @@ -32,20 +32,16 @@ :data [{:_type "TEXT" :role "assistant" :content "hi"} {:_type "TOOL_CALL" :role "assistant" :tool_calls [{:id "x"}]} {:_type "TOOL_RESULT" :role "tool" :tool_call_id "x" :content "y"}]}) - (testing "only TOOL_CALL and TOOL_RESULT are included, TEXT is filtered out" (let [result (slackbot.persistence/message-history conv-id #{"1709567890.000002"})] (is (= 2 (count (get result "1709567890.000002")))) (is (every? #(#{:assistant :tool} (:role %)) (get result "1709567890.000002"))))) - (testing "user messages are excluded, only assistant role is queried" (let [result (slackbot.persistence/message-history conv-id #{"1709567890.000001"})] (is (empty? result)))) - (testing "non-matching slack_msg_ids return empty map" (let [result (slackbot.persistence/message-history conv-id #{"nonexistent-id"})] (is (empty? result)))) - (testing "soft-deleted messages are excluded from message-history but included in deleted-message-ids" (let [deleted-ts "1709567890.000003"] (t2/insert! :model/MetabotMessage @@ -180,7 +176,6 @@ (is (some? (:deleted_at msg)))) (testing "deleted_by_user_id is set" (is (= user-id (:deleted_by_user_id msg)))))))) - (testing "returns nil when required inputs are missing" (is (nil? (slackbot.persistence/soft-delete-response! nil "ts" 1))) (is (nil? (slackbot.persistence/soft-delete-response! "C123" nil 1))) @@ -211,7 +206,6 @@ (is (nil? (slackbot.persistence/response-owner-user-id channel-id "nonexistent-ts")))) (testing "returns nil when channel does not match" (is (nil? (slackbot.persistence/response-owner-user-id "C-WRONG" slack-ts)))) - (testing "two users in the same thread each own only their own bot response" (let [second-slack-ts "1709567890.333333"] (t2/insert! :model/MetabotMessage diff --git a/test/metabase/slackbot/query_test.clj b/test/metabase/slackbot/query_test.clj index bce393233b17..53a3865d4fc8 100644 --- a/test/metabase/slackbot/query_test.clj +++ b/test/metabase/slackbot/query_test.clj @@ -119,17 +119,14 @@ {:name "name" :display_name "Name" :base_type :type/Text} {:name "amount" :display_name "Amount" :base_type :type/Float}]}} blocks (slackbot.query/format-results-as-table-blocks results)] - (testing "returns a vector of blocks" (is (vector? blocks)) (is (pos? (count blocks)))) - (testing "first block is a table block" (let [table-block (first blocks)] (is (= "table" (:type table-block))) (is (contains? table-block :rows)) (is (contains? table-block :column_settings)))) - (testing "table has header row plus data rows" (let [table-block (first blocks) rows (:rows table-block)] @@ -139,7 +136,6 @@ (is (= "ID" (get-in header-row [0 :text]))) (is (= "Name" (get-in header-row [1 :text]))) (is (= "Amount" (get-in header-row [2 :text]))))))) - (testing "column settings align numeric columns right" (let [table-block (first blocks) settings (:column_settings table-block)] @@ -152,19 +148,16 @@ results {:data {:rows many-rows :cols [{:name "id"} {:name "name"}]}} blocks (slackbot.query/format-results-as-table-blocks results)] - (testing "table is truncated to 99 data rows plus header" (let [table-block (first blocks) rows (:rows table-block)] (is (= 100 (count rows))))) - (testing "includes truncation message" (is (= 2 (count blocks))) (let [context-block (second blocks)] (is (= "context" (:type context-block))) (is (= "Showing 99 of 150 rows" (get-in context-block [:elements 0 :text]))))))) - (testing "format-results-as-table-blocks handles scalar (single-cell) results" (let [results {:data {:rows [[42]] :cols [{:name "count" :display_name "Count" :base_type :type/Integer}]}} @@ -175,14 +168,12 @@ (is (= 2 (count rows))) ; 1 header + 1 data row (is (= "Count" (get-in rows [0 0 :text]))) (is (= "42" (get-in rows [1 0 :text]))))))) - (testing "format-results-as-table-blocks handles empty results" (let [results {:data {:rows [] :cols [{:name "count" :display_name "Count" :base_type :type/Integer}]}} blocks (slackbot.query/format-results-as-table-blocks results)] (testing "returns no-data block" (is (= "No data" (get-in blocks [0 :rows 0 0 :text])))))) - (testing "format-results-as-table-blocks truncates long cell values" (binding [slackbot.query/*slack-table-max-cell-length* 20] (let [long-text (apply str (repeat 50 "x")) @@ -193,7 +184,6 @@ cell-text (get-in (first blocks) [:rows 1 1 :text])] (is (<= (count cell-text) 20)) (is (str/ends-with? cell-text "…"))))) - (testing "format-results-as-table-blocks truncates rows to fit character budget" (binding [slackbot.query/*slack-table-max-chars* 100] (let [results {:data {:rows (vec (repeat 20 ["abcdefghij"])) @@ -209,7 +199,6 @@ (let [total-chars (transduce (comp cat (map (comp count :text))) + (:rows (first blocks)))] (is (<= total-chars 100))))))) - (testing "format-results-as-table-blocks handles FK remapped columns" (let [;; Simulate FK remapping: USER_ID is remapped to show USER.NAME ;; The data has both USER_ID (raw FK) and NAME (human-readable value from FK target) @@ -227,19 +216,16 @@ :base_type :type/Text :remapped_from "user_id"}]}} blocks (slackbot.query/format-results-as-table-blocks results)] - (testing "skips remapped_from column (the duplicate)" (let [table-block (first blocks) header-row (first (:rows table-block))] ;; Should only have 2 columns: ID and User Name (not 3) (is (= 2 (count header-row))))) - (testing "uses remapped column's display name in header" (let [table-block (first blocks) header-row (first (:rows table-block))] (is (= "ID" (get-in header-row [0 :text]))) (is (= "User Name" (get-in header-row [1 :text]))))) - (testing "substitutes remapped values in data rows" (let [table-block (first blocks) data-rows (rest (:rows table-block))] @@ -259,18 +245,15 @@ query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues))) (lib/aggregate (lib/count)) (lib/breakout (lib.metadata/field mp (mt/id :venues :category_id))))] - (testing "bar chart renders as image" (let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :bar)] (is (= :image type)) (is (bytes? content)) (is (some? (bytes->image content))))) - (testing "line chart renders as image" (let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :line)] (is (= :image type)) (is (bytes? content)))) - (testing "pie chart renders as image" (let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :pie)] (is (= :image type)) @@ -282,13 +265,11 @@ (let [mp (mt/metadata-provider) query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues))) (lib/limit 5))] - (testing "table display renders as table blocks" (let [{:keys [type content]} (slackbot.query/generate-adhoc-output query :display :table)] (is (= :table type)) (is (vector? content)) (is (= "table" (:type (first content)))))) - (testing "scalar display renders as table blocks" (let [scalar-query (-> (lib/query mp (lib.metadata/table mp (mt/id :venues))) (lib/aggregate (lib/count))) @@ -315,7 +296,6 @@ :rows [[1] [2]]}}] (mt/with-dynamic-fn-redefs [slackbot.query/pulse-card-query-results (constantly mock-results)] - (testing "supported display types return :image" (doseq [display [:bar :line :pie :area :row :scatter :funnel :waterfall :combo :progress :gauge @@ -324,7 +304,6 @@ (mt/with-temp [:model/Card {card-id :id} {:display display}] (let [result (#'slackbot.query/generate-card-output card-id)] (is (= :image (:type result)))))))) - (testing "unsupported display types return :table" (doseq [display [:table :pin_map :state :country :map :pivot :scalar]] (testing (str "display type: " display) diff --git a/test/metabase/slackbot/streaming_test.clj b/test/metabase/slackbot/streaming_test.clj index d22ff0afee40..220264d932e9 100644 --- a/test/metabase/slackbot/streaming_test.clj +++ b/test/metabase/slackbot/streaming_test.clj @@ -22,19 +22,15 @@ (testing "Same thread produces same conversation ID" (is (= (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456") (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456")))) - (testing "Different threads produce different IDs" (is (not= (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456") (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "789.012")))) - (testing "Different channels produce different IDs" (is (not= (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456") (#'slackbot.streaming/slack-thread->conversation-id "T1" "C2" "123.456")))) - (testing "Different workspaces produce different IDs" (is (not= (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456") (#'slackbot.streaming/slack-thread->conversation-id "T2" "C1" "123.456")))) - (testing "Result is valid UUID format" (is (re-matches #"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" (#'slackbot.streaming/slack-thread->conversation-id "T1" "C1" "123.456"))))) diff --git a/test/metabase/source_swap/native_test.clj b/test/metabase/source_swap/native_test.clj index 305c8a75b252..bd86cbb337bf 100644 --- a/test/metabase/source_swap/native_test.clj +++ b/test/metabase/source_swap/native_test.clj @@ -29,7 +29,6 @@ (is (= "SELECT * FROM {{#2}}" (lib/raw-native-query result))) (is (= 2 (get-in (lib/template-tags result) ["#2" :card-id]))))) - (testing "Multiple card tags, only matching one is replaced" (let [query (-> (lib/native-query mp "SELECT * FROM {{#1}} JOIN {{#3}} ON 1=1") (lib/with-template-tags {"#1" {:type :card :card-id 1 :name "#1" :display-name "#1"} @@ -63,7 +62,6 @@ (is (= "SELECT * FROM {{#2}} [[WHERE {{created_at}}]]" (lib/raw-native-query result))) (is (= 2 (get-in (lib/template-tags result) ["#2" :card-id]))))) - (testing "Card tag inside optional clause" (let [query (-> (lib/native-query mp "SELECT * FROM foo [[JOIN {{#1}} ON 1=1]]") (lib/with-template-tags {"#1" {:type :card :card-id 1 :name "#1" :display-name "#1"}})) @@ -81,7 +79,6 @@ (is (= "SELECT * FROM {{#2}}\n-- old: {{#1}}" (lib/raw-native-query result)) "The tag in the comment should be left alone"))) - (testing "Card tag inside a block comment should NOT be replaced" (let [query (-> (lib/native-query mp "SELECT * FROM {{#1}} /* see also {{#1}} */") (lib/with-template-tags {"#1" {:type :card :card-id 1 :name "#1" :display-name "#1"}})) @@ -274,7 +271,6 @@ {:schema "PUBLIC" :table "NEW_ORDERS"})] (is (str/includes? result "NEW_ORDERS")) (is (not (str/includes? result "ORDERS "))))) - (testing "Cross-schema rename: PUBLIC.ORDERS → ANALYTICS.NEW_ORDERS" (let [result (#'source-swap.native/replace-table-in-native-sql :h2 "SELECT * FROM PUBLIC.ORDERS" @@ -283,14 +279,12 @@ (is (str/includes? result "ANALYTICS")) (is (str/includes? result "NEW_ORDERS")) (is (not (str/includes? result "PUBLIC"))))) - (testing "Unqualified SQL still matches when old-table has schema" (let [result (#'source-swap.native/replace-table-in-native-sql :h2 "SELECT * FROM ORDERS" {:schema "PUBLIC" :table "ORDERS"} {:schema "PUBLIC" :table "NEW_ORDERS"})] (is (str/includes? result "NEW_ORDERS")))) - (testing "Schema-qualified table→card clears the schema (no PUBLIC.{{#card}} in output)" ;; Just a plain string — replace-table-in-native-sql infers schema clearing ;; because old-table has a schema and new-table doesn't @@ -302,7 +296,6 @@ (is (not (str/includes? result "PUBLIC.{{")) "Schema must be cleared, not left as PUBLIC.{{#card}}") (is (not (str/includes? result "PUBLIC"))))) - (testing "Card reference must not be quoted in SQL output" (let [result (#'source-swap.native/replace-table-in-native-sql :h2 "SELECT * FROM ORDERS" @@ -321,7 +314,6 @@ [:table (meta/id :orders)])] (is (str/includes? (lib/raw-native-query result) "ORDERS")) (is (not (str/includes? (lib/raw-native-query result) "PRODUCTS"))))) - (testing "table→table: template tags are preserved" (let [query (lib/native-query meta/metadata-provider "SELECT * FROM PRODUCTS WHERE category = {{category}}") result (source-swap.native/swap-source-in-native-stages query @@ -330,7 +322,6 @@ (is (str/includes? (lib/raw-native-query result) "ORDERS")) (is (not (str/includes? (lib/raw-native-query result) "PRODUCTS"))) (is (str/includes? (lib/raw-native-query result) "{{category}}")))) - (testing "table→table: only the target table is renamed in a JOIN" (let [query (lib/native-query meta/metadata-provider "SELECT o.*, p.title FROM ORDERS o JOIN PRODUCTS p ON o.product_id = p.id") result (source-swap.native/swap-source-in-native-stages query @@ -339,7 +330,6 @@ (is (str/includes? (lib/raw-native-query result) "REVIEWS")) (is (str/includes? (lib/raw-native-query result) "PRODUCTS")) (is (not (str/includes? (lib/raw-native-query result) "ORDERS"))))) - (testing "table→table: schema-qualified SQL reference is replaced" (let [query (lib/native-query meta/metadata-provider "SELECT * FROM PUBLIC.PRODUCTS") result (source-swap.native/swap-source-in-native-stages query @@ -360,7 +350,6 @@ (is (str/includes? (lib/raw-native-query result) "{{#1-card-1}}")) (is (not (str/includes? (lib/raw-native-query result) "PRODUCTS"))) (is (= 1 (get-in (lib/template-tags result) ["#1-card-1" :card-id]))))) - (testing "table→card: existing template tags are preserved" (let [query (lib/native-query mp "SELECT * FROM PRODUCTS WHERE category = {{category}}") result (source-swap.native/swap-source-in-native-stages query @@ -370,7 +359,6 @@ (is (str/includes? (lib/raw-native-query result) "{{category}}")) (is (contains? (lib/template-tags result) "category")) (is (= 1 (get-in (lib/template-tags result) ["#1-card-1" :card-id]))))) - (testing "table→card: schema-qualified SQL gets card ref without schema prefix" (let [query (lib/native-query mp "SELECT * FROM PUBLIC.PRODUCTS") result (source-swap.native/swap-source-in-native-stages query @@ -392,7 +380,6 @@ (is (str/includes? (lib/raw-native-query result) "ORDERS")) (is (not (str/includes? (lib/raw-native-query result) "{{#1"))) (is (empty? (filter #(= (:card-id %) 1) (vals (lib/template-tags result))))))) - (testing "card→table: other template tags are preserved" (let [query (-> (lib/native-query meta/metadata-provider "SELECT * FROM {{#1-card-1}} WHERE status = {{status}}") (lib/with-template-tags {"#1-card-1" {:type :card :card-id 1 :name "#1-card-1" :display-name "#1-card-1"}})) diff --git a/test/metabase/sql_parsing/core_test.clj b/test/metabase/sql_parsing/core_test.clj index eef080a9e888..556a00492096 100644 --- a/test/metabase/sql_parsing/core_test.clj +++ b/test/metabase/sql_parsing/core_test.clj @@ -13,13 +13,11 @@ (testing "Simple table extraction" (is (= [[nil nil "users"]] (sql-parsing/referenced-tables "postgres" "SELECT * FROM users")))) - (testing "Multiple tables from JOIN" (is (= [[nil nil "orders"] [nil nil "users"]] (sql-parsing/referenced-tables "postgres" "SELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id")))) - (testing "Schema-qualified tables are preserved" (is (= [[nil "other" "users"] [nil "public" "users"]] (sql-parsing/referenced-tables @@ -27,7 +25,6 @@ "SELECT public.users.id, other.users.id FROM public.users u1 LEFT JOIN other.users u2 ON u1.id = u2.id")))) - (testing "CTE names are excluded" (is (= [[nil nil "users"]] (sql-parsing/referenced-tables @@ -155,7 +152,6 @@ (format "dialect %s: should return vector, got %s" dialect (type result))) (is (every? vector? result) (format "dialect %s: each element should be [catalog schema table] tuple" dialect))))) - (doseq [[dialect sql] udtf-with-table-queries] (testing (str "dialect: " dialect " - UDTF mixed with real table") (let [result (sql-parsing/referenced-tables (name dialect) sql)] @@ -290,18 +286,14 @@ (testing "Valid queries against schema" (testing "simple select with existing columns" (is (= "ok" (:status (sql-parsing/validate-query nil "SELECT id, title FROM products" "PUBLIC" test-schema))))) - (testing "wildcard select" (is (= "ok" (:status (sql-parsing/validate-query nil "SELECT * FROM products" "PUBLIC" test-schema))))) - (testing "table-qualified columns" (is (= "ok" (:status (sql-parsing/validate-query nil "SELECT products.id, products.title FROM products" "PUBLIC" test-schema))))) - (testing "join with valid columns" (is (= "ok" (:status (sql-parsing/validate-query nil "SELECT o.id, p.title FROM orders o JOIN products p ON o.product_id = p.id" "PUBLIC" test-schema))))) - (testing "subquery" (is (= "ok" (:status (sql-parsing/validate-query nil "SELECT * FROM (SELECT id, title FROM products) AS sub" @@ -314,7 +306,6 @@ (is (= "error" (:status result))) (is (= "column_not_resolved" (:type result))) (is (re-find #"(?i)bad_column" (:column result))))) - (testing "column from wrong table" (let [result (sql-parsing/validate-query nil "SELECT email FROM products" "PUBLIC" test-schema)] (is (= "error" (:status result))) @@ -327,7 +318,6 @@ (is (= "error" (:status result))) ;; SQLGlot reports this as unknown_table (is (contains? #{"unknown_table" "column_not_resolved"} (:type result))))) - (testing "qualified column with non-existent alias" (let [result (sql-parsing/validate-query nil "SELECT p.id FROM products" "PUBLIC" test-schema)] (is (= "error" (:status result))))))) @@ -354,7 +344,6 @@ (let [result (sql-parsing/referenced-tables "postgres" "SELECT 1 LIMIT")] (is (= [] result) "No tables are referenced in 'SELECT 1 LIMIT'"))) - (testing "To reliably trigger SQL errors, use nonexistent tables instead" ;; This is the recommended pattern for tests that need to trigger SQL failures (let [result (sql-parsing/referenced-tables "postgres" "SELECT * FROM nonexistent_table_xyz")] @@ -372,7 +361,6 @@ (let [driver (first drivers) corpus (slurp (str query-corpus-path driver ".log")) queries (sort (distinct (str/split corpus sentinel)))] - (frequencies (doall (for [q queries] @@ -455,11 +443,9 @@ (testing "Small VALUES clauses are preserved" (let [sql "SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t(id, name)"] (is (= sql (sql-parsing/strip-large-values sql))))) - (testing "No VALUES keyword returns SQL unchanged" (let [sql "SELECT * FROM users WHERE id = 1"] (is (= sql (sql-parsing/strip-large-values sql))))) - (testing "Large VALUES clause is replaced with NULLs preserving column count" (let [tuples (clojure.string/join ", " (map #(format "(%d, '%s', %d)" % (str "name" %) (* % 10)) (range 200))) @@ -468,7 +454,6 @@ (is (clojure.string/includes? result "VALUES (NULL, NULL, NULL)")) (is (clojure.string/includes? result "AS t(id, name, score)")) (is (not (clojure.string/includes? result "name0"))))) - (testing "Multiple large VALUES clauses are all stripped" (let [tuples1 (clojure.string/join ", " (map #(format "(%d)" %) (range 200))) tuples2 (clojure.string/join ", " (map #(format "(%d, %d)" % (* % 2)) (range 200))) @@ -478,25 +463,20 @@ result (sql-parsing/strip-large-values sql)] (is (clojure.string/includes? result "VALUES (NULL)")) (is (clojure.string/includes? result "VALUES (NULL, NULL)")))) - (testing "VALUES keyword casing is preserved" (let [tuples (clojure.string/join ", " (map #(format "(%d)" %) (range 200))) sql (str "select * from (values " tuples ") as t(x)") result (sql-parsing/strip-large-values sql)] (is (clojure.string/includes? result "values (NULL)")))) - (testing "VALUES inside a string literal is not stripped" (let [sql "SELECT 'INSERT INTO foo VALUES (1,2,3)' AS example FROM bar"] (is (= sql (sql-parsing/strip-large-values sql))))) - (testing "Column named values is not stripped" (let [sql "SELECT values FROM my_table WHERE values > 10"] (is (= sql (sql-parsing/strip-large-values sql))))) - (testing "VALUES keyword not followed by paren is not stripped" (let [sql "SELECT * FROM t WHERE col IN (SELECT values FROM other)"] (is (= sql (sql-parsing/strip-large-values sql))))) - (testing "INSERT INTO ... VALUES is stripped when large" (let [tuples (clojure.string/join ", " (map #(format "(%d, 'x')" %) (range 200))) sql (str "INSERT INTO foo VALUES " tuples) @@ -529,18 +509,15 @@ (testing "Unqualified wildcard - single table" (let [result (sql-parsing/referenced-fields "postgres" "SELECT * FROM users")] (is (fields-match? [["users" "*"]] result)))) - (testing "Unqualified wildcard - multiple tables" (let [result (sql-parsing/referenced-fields "postgres" "SELECT * FROM users u LEFT JOIN orders o ON u.id = o.user_id")] (is (some #(= ["orders" "*"] %) (normalize-fields result)) "Should include orders wildcard") (is (some #(= ["users" "*"] %) (normalize-fields result)) "Should include users wildcard"))) - (testing "Qualified wildcard" (let [result (sql-parsing/referenced-fields "postgres" "SELECT u.* FROM users u")] (is (fields-match? [["users" "*"]] result)))) - (testing "Mixed wildcards and specific columns" (let [result (sql-parsing/referenced-fields "postgres" "SELECT u.*, t.total FROM users u, transactions t WHERE u.id = t.user_id")] (is (some #(= ["users" "*"] %) (normalize-fields result)) diff --git a/test/metabase/sql_parsing/pool_test.clj b/test/metabase/sql_parsing/pool_test.clj index a1853cb534af..30fa0a8d0096 100644 --- a/test/metabase/sql_parsing/pool_test.clj +++ b/test/metabase/sql_parsing/pool_test.clj @@ -76,7 +76,6 @@ (fn [_i ctx] (Thread/sleep (long (+ 10 (rand-int 50)))) ctx))] - (is (<= @created-count 3) (str "Pool created " @created-count " contexts but max is 3")) (is (= num-threads (count results)) @@ -99,7 +98,6 @@ (is (some? context) "Context should be created") (is (number? expiry-ts) "Expiry timestamp should be set") (is (< (System/nanoTime) expiry-ts) "Expiry should be in the future") - (let [expiry-in-minutes (/ (- expiry-ts (System/nanoTime)) (* 1000000000 60))] (is (< 9 expiry-in-minutes 11) "Expiry should be approximately 10 minutes")) (finally @@ -109,11 +107,9 @@ (testing "with-pooled-context properly handles and replaces expired contexts" (let [[pool created-count] (test-pool)] (with-pool pool (fn [_ctx] (is (= 1 @created-count)))) - ;; Manually expire by disposing (let [tuple (.acquire ^Pool pool :python)] (.dispose ^Pool pool :python tuple)) - (with-pool pool (fn [_ctx] (is (= 2 @created-count) "Expired context should be replaced")))))) ;;; --------------------------------------------- Generator Failure Tests -------------------------------------------- @@ -123,13 +119,10 @@ (let [counter (atom 0) generator (failing-generator counter 1) pool (#'pool/make-python-context-pool generator)] - (is (some? (with-pool pool identity)) "First context creation succeeds") - ;; Dispose to force new creation (let [tuple (.acquire ^Pool pool :python)] (.dispose ^Pool pool :python tuple)) - (is (thrown? Exception (with-pool pool identity)) "Generator failure should propagate")))) ;;; ------------------------------------------- Concurrent Access Tests ---------------------------------------------- @@ -142,7 +135,6 @@ (fn [i _ctx] (Thread/sleep (long (rand-int 20))) i))] - (is (= num-threads (count results)) "All threads should complete successfully") (is (<= @created-count 3) (str "Created " @created-count " contexts, expected <= 3"))))) @@ -153,11 +145,9 @@ (let [[pool created-count] (test-pool) result1 (with-pool pool :context) result2 (with-pool pool :context)] - (is (map? result1)) (is (= 1 (:context-id result1))) (is (= 1 @created-count)) - (is (map? result2)) (is (= 1 (:context-id result2))) ;; Same context ID (is (= 1 @created-count))))) diff --git a/test/metabase/sso/api/ldap_test.clj b/test/metabase/sso/api/ldap_test.clj index 02482f457841..06937b95b7ce 100644 --- a/test/metabase/sso/api/ldap_test.clj +++ b/test/metabase/sso/api/ldap_test.clj @@ -20,18 +20,15 @@ (ldap.test/with-ldap-server! (testing "Valid LDAP settings can be saved via an API call" (mt/user-http-request :crowberto :put 200 "ldap/settings" (ldap-test-details))) - (testing "Invalid LDAP settings return a server error" (is (= {:errors {:ldap-password "Password was incorrect"}} (mt/user-http-request :crowberto :put 500 "ldap/settings" (assoc (ldap-test-details) :ldap-password "wrong-password"))))) - (testing "Unreachable port setting returns a server error" (is (= {:errors {:ldap-host "Wrong host or port" :ldap-port "Wrong host or port"}} (mt/user-http-request :crowberto :put 500 "ldap/settings" (assoc (ldap-test-details) :ldap-port 5299))))) - (testing "LDAP settings that don't match the provided schema error" (is (= {:specific-errors {:ldap-enabled ["should be a boolean, received: 0"]} :errors {:ldap-enabled "nullable boolean"}} @@ -49,31 +46,25 @@ :errors {:ldap-port "nullable integer greater than 0"}} (mt/user-http-request :crowberto :put 400 "ldap/settings" (assoc (ldap-test-details) :ldap-port "0"))))) - (testing "Valid LDAP settings can still be saved if port is a integer (#18936)" (mt/user-http-request :crowberto :put 200 "ldap/settings" (assoc (ldap-test-details) :ldap-port (int (ldap.test/get-ldap-port))))) - (testing "Passing ldap-enabled=false will disable LDAP" (mt/user-http-request :crowberto :put 200 "ldap/settings" (ldap-test-details false)) (is (not (sso.settings/ldap-enabled)))) - (testing "Passing ldap-enabled=false still validates the LDAP settings" (mt/user-http-request :crowberto :put 500 "ldap/settings" (assoc (ldap-test-details false) :ldap-password "wrong-password"))) - (with-redefs [ldap/test-ldap-connection (constantly {:status :SUCCESS})] (testing "LDAP port is saved as default value if passed as an empty string (#18936)" (is (true? (mt/user-http-request :crowberto :put 200 "ldap/settings" (assoc (ldap-test-details) :ldap-port "")))) (is (= 389 (sso.settings/ldap-port))))) - (testing "Could update with obfuscated password" (mt/user-http-request :crowberto :put 200 "ldap/settings" (update (ldap-test-details) :ldap-password setting/obfuscate-value))) - (testing "Requires superusers" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 "ldap/settings" diff --git a/test/metabase/sso/common_test.clj b/test/metabase/sso/common_test.clj index 4c37e5f9fd67..d15fa8004544 100644 --- a/test/metabase/sso/common_test.clj +++ b/test/metabase/sso/common_test.clj @@ -205,13 +205,11 @@ (integrations.common/sync-group-memberships! user #{}) (is (= #{"All Users"} (group-memberships user))))) - (testing "add admin role when in new groups" (mt/with-user-in-groups [user []] (integrations.common/sync-group-memberships! user #{(perms-group/admin)}) (is (= #{"All Users" "Administrators"} (group-memberships user))))) - (testing "keep admin role when already present and in new groups" (mt/with-user-in-groups [user [(perms-group/admin)]] (integrations.common/sync-group-memberships! user #{(perms-group/admin)}) diff --git a/test/metabase/sso/google_test.clj b/test/metabase/sso/google_test.clj index afcb0836e73c..448a393eb9b9 100644 --- a/test/metabase/sso/google_test.clj +++ b/test/metabase/sso/google_test.clj @@ -22,11 +22,9 @@ clojure.lang.ExceptionInfo #"Invalid Google Sign-In Client ID: must end with \".apps.googleusercontent.com\"" (sso.settings/google-auth-client-id! "invalid-client-id")))) - (testing "Trailing whitespace in client ID is stripped upon save" (sso.settings/google-auth-client-id! "test-client-id.apps.googleusercontent.com ") (is (= "test-client-id.apps.googleusercontent.com" (sso.settings/google-auth-client-id)))) - (testing "Saving an empty string will clear the client ID setting" (sso.settings/google-auth-client-id! "") (is (= nil (sso.settings/google-auth-client-id)))))) @@ -65,7 +63,6 @@ (#'google/google-auth-token-info {:status 400} "") (catch Exception e [(-> e ex-data :status-code) (.getMessage e)]))))) - (testing "for invalid data." (is (= [400 "Google Sign-In token appears to be incorrect. Double check that it matches in Google and Metabase."] (try @@ -94,7 +91,6 @@ "PRETEND-GOOD-GOOGLE-CLIENT-ID") (catch Exception e [(-> e ex-data :status-code) (.getMessage e)])))))) - (testing "Supports multiple :aud token data fields" (let [token-1 "GOOGLE-CLIENT-ID-1" token-2 "GOOGLE-CLIENT-ID-2"] diff --git a/test/metabase/sso/integrations/slack_connect_test.clj b/test/metabase/sso/integrations/slack_connect_test.clj index 8821d423b083..3e207447bb2b 100644 --- a/test/metabase/sso/integrations/slack_connect_test.clj +++ b/test/metabase/sso/integrations/slack_connect_test.clj @@ -153,7 +153,7 @@ {:request-options {:redirect-strategy :none}} :code "test-code" :state "some-state")] - ;; Without a state cookie, the callback fails with invalid/expired state error + ;; Without a state cookie, the callback fails with invalid/expired state error (is (str/includes? (:body response) "OIDC state cookie is invalid, expired, or missing"))))))) (deftest callback-state-validation-csrf-test @@ -161,11 +161,11 @@ (with-test-encryption! (sso.test-helpers/with-slack-default-setup! (with-successful-oidc! - ;; First, initiate auth to set state cookie + ;; First, initiate auth to set state cookie (let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) - ;; Convert Set-Cookie headers to Cookie header format (extract name=value parts) + ;; Convert Set-Cookie headers to Cookie header format (extract name=value parts) set-cookies (get-in init-response [:headers "Set-Cookie"]) cookie-header (->> set-cookies (map #(first (str/split % #";"))) ; Extract name=value before first ; @@ -175,7 +175,7 @@ :headers {"Cookie" cookie-header}}} :code "test-code" :state "wrong-state")] - ;; State mismatch should indicate possible CSRF attack + ;; State mismatch should indicate possible CSRF attack (is (str/includes? (str (:body response)) "CSRF")))))))) (deftest happy-path-callback-test @@ -183,11 +183,11 @@ (with-test-encryption! (sso.test-helpers/with-slack-default-setup! (with-successful-oidc! - ;; First, initiate auth to set state cookie + ;; First, initiate auth to set state cookie (let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) - ;; Convert Set-Cookie headers to Cookie header format + ;; Convert Set-Cookie headers to Cookie header format set-cookies (get-in init-response [:headers "Set-Cookie"]) cookie-header (->> set-cookies (map #(first (str/split % #";"))) @@ -251,11 +251,11 @@ (letfn [(new-user-exists? [] (boolean (seq (t2/select :model/User :%lower.email "example@slack.com"))))] (is (false? (new-user-exists?))) - ;; Initiate auth + ;; Initiate auth (let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) - ;; Convert Set-Cookie headers to Cookie header format + ;; Convert Set-Cookie headers to Cookie header format set-cookies (get-in init-response [:headers "Set-Cookie"]) cookie-header (->> set-cookies (map #(first (str/split % #";"))) @@ -265,7 +265,7 @@ :headers {"Cookie" cookie-header}}} :code "test-code" :state "test-state")] - ;; Complete callback + ;; Complete callback (is (sso.test-helpers/successful-login? response)) (let [new-user (t2/select-one :model/User :email "example@slack.com")] (testing "new user" @@ -302,7 +302,7 @@ {:success? false :error :user-provisioning-disabled :message "Sorry, but you'll need a test account to view this page. Please contact your administrator."})] - ;; Initiate auth + ;; Initiate auth (let [init-response (mt/client-full-response :get 302 "/auth/sso/slack-connect" {:request-options {:redirect-strategy :none}} :redirect default-redirect-uri) @@ -310,7 +310,7 @@ cookie-header (->> set-cookies (map #(first (str/split % #";"))) (str/join "; "))] - ;; Try callback - should fail + ;; Try callback - should fail (mt/client-real-response :get 401 "/auth/sso/slack-connect/callback" {:request-options {:redirect-strategy :none :headers {"Cookie" cookie-header}}} @@ -331,7 +331,6 @@ (is (= "sso" (sso-settings/slack-connect-authentication-mode))) (sso-settings/slack-connect-authentication-mode! "link-only") (is (= "link-only" (sso-settings/slack-connect-authentication-mode)))) - (testing "invalid values are rejected" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/sso/ldap_test.clj b/test/metabase/sso/ldap_test.clj index 0c7061e3c144..f8115ea0bc25 100644 --- a/test/metabase/sso/ldap_test.clj +++ b/test/metabase/sso/ldap_test.clj @@ -20,48 +20,37 @@ (testing "successfully connect to IPv4 host" (is (= {:status :SUCCESS} (ldap/test-ldap-connection (ldap.test/get-ldap-details)))))) - (testing "invalid user search base" (is (= :ERROR (:status (ldap/test-ldap-connection (assoc (ldap.test/get-ldap-details) :user-base "dc=example,dc=com")))))) - (testing "invalid group search base" (is (= :ERROR (:status (ldap/test-ldap-connection (assoc (ldap.test/get-ldap-details) :group-base "dc=example,dc=com")))))) - (testing "invalid bind DN" (is (= :ERROR (:status (ldap/test-ldap-connection (assoc (ldap.test/get-ldap-details) :bind-dn "cn=Not Directory Manager")))))) - (testing "invalid bind password" (is (= :ERROR (:status (ldap/test-ldap-connection (assoc (ldap.test/get-ldap-details) :password "wrong")))))) - (testing "basic get-connection works, will throw otherwise" (is (= nil (.close ^LDAPConnectionPool (#'ldap/get-connection))))) - (testing "login should succeed" (is (true? (ldap/verify-password "cn=Directory Manager" "password")))) - (testing "wrong password" (is (= false (ldap/verify-password "cn=Directory Manager" "wrongpassword")))) - (testing "invalid DN fails" (is (= false (ldap/verify-password "cn=Nobody,ou=nowhere,dc=metabase,dc=com" "password")))) - (testing "regular user login" (is (true? (ldap/verify-password "cn=Sally Brown,ou=People,dc=metabase,dc=com" "1234")))) - (testing "fail regular user login with bad password" (is (= false (ldap/verify-password "cn=Sally Brown,ou=People,dc=metabase,dc=com" "password")))) - (testing "password containing dollar signs succeeds (#15145)" (is (true? (ldap/verify-password "cn=Fred Taylor,ou=People,dc=metabase,dc=com", "pa$$word")))))) @@ -77,7 +66,6 @@ :email "John.Smith@metabase.com" :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} (ldap/find-user "jsmith1")))) - (testing "find by email" (is (= {:dn "cn=John Smith,ou=People,dc=metabase,dc=com" :first-name "John" @@ -85,7 +73,6 @@ :email "John.Smith@metabase.com" :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} (ldap/find-user "John.Smith@metabase.com")))) - (testing "find by email, no groups" (is (= {:dn "cn=Fred Taylor,ou=People,dc=metabase,dc=com" :first-name "Fred" @@ -93,7 +80,6 @@ :email "fred.taylor@metabase.com" :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} (ldap/find-user "fred.taylor@metabase.com")))) - (testing "find by email, no givenName" (is (= {:dn "cn=Jane Miller,ou=People,dc=metabase,dc=com" :first-name nil @@ -101,7 +87,6 @@ :email "jane.miller@metabase.com" :groups []} (ldap/find-user "jane.miller@metabase.com"))))) - ;; Test group lookups for directory servers that use the memberOf attribute overlay, such as Active Directory (ldap.test/with-active-directory-ldap-server! (testing "find user with one group using memberOf attribute" @@ -111,7 +96,6 @@ :email "John.Smith@metabase.com" :groups ["cn=Accounting,ou=Groups,dc=metabase,dc=com"]} (ldap/find-user "jsmith1")))) - (testing "find user with two groups using memberOf attribute" (is (= {:dn "cn=Sally Brown,ou=People,dc=metabase,dc=com" :first-name "Sally" diff --git a/test/metabase/sso/oidc/common_test.clj b/test/metabase/sso/oidc/common_test.clj index 58eda4814012..8b2d2aec31db 100644 --- a/test/metabase/sso/oidc/common_test.clj +++ b/test/metabase/sso/oidc/common_test.clj @@ -9,12 +9,10 @@ (let [state (oidc.common/generate-state)] (is (string? state)) (is (pos? (count state))))) - (testing "Generates unique state tokens" (let [state1 (oidc.common/generate-state) state2 (oidc.common/generate-state)] (is (not= state1 state2)))) - (testing "State token is URL-safe (no special characters)" (let [state (oidc.common/generate-state)] (is (re-matches #"[A-Za-z0-9\-_]+" state))))) @@ -24,12 +22,10 @@ (let [nonce (oidc.common/generate-nonce)] (is (string? nonce)) (is (pos? (count nonce))))) - (testing "Generates unique nonces" (let [nonce1 (oidc.common/generate-nonce) nonce2 (oidc.common/generate-nonce)] (is (not= nonce1 nonce2)))) - (testing "Nonce is URL-safe (no special characters)" (let [nonce (oidc.common/generate-nonce)] (is (re-matches #"[A-Za-z0-9\-_]+" nonce))))) @@ -40,22 +36,18 @@ query-string (oidc.common/build-query-string params)] (is (or (= query-string "foo=bar&baz=qux") (= query-string "baz=qux&foo=bar"))))) - (testing "URL-encodes parameter values" (let [params {:redirect_uri "https://example.com/callback?foo=bar"} query-string (oidc.common/build-query-string params)] (is (= "redirect_uri=https%3A%2F%2Fexample.com%2Fcallback%3Ffoo%3Dbar" query-string)))) - (testing "URL-encodes parameter names" (let [params {:some-param "value"} query-string (oidc.common/build-query-string params)] (is (= "some-param=value" query-string)))) - (testing "Handles spaces in values" (let [params {:scope "openid profile email"} query-string (oidc.common/build-query-string params)] (is (= "scope=openid%20profile%20email" query-string)))) - (testing "Handles empty params" (let [query-string (oidc.common/build-query-string {})] (is (= "" query-string))))) @@ -77,7 +69,6 @@ (is (str/includes? url "scope=openid%20email%20profile")) (is (str/includes? url "state=test-state")) (is (str/includes? url "nonce=test-nonce")))) - (testing "Properly encodes redirect URI" (let [url (oidc.common/generate-authorization-url "https://provider.com/authorize" @@ -87,7 +78,6 @@ "state" "nonce")] (is (str/includes? url "redirect_uri=https%3A%2F%2Fmetabase.com%2Fauth%2Foidc%2Fcallback%3Ffoo%3Dbar")))) - (testing "Joins multiple scopes with spaces" (let [url (oidc.common/generate-authorization-url "https://provider.com/authorize" @@ -108,7 +98,6 @@ :other-key "ignored"} extracted (oidc.common/extract-oidc-config request)] (is (= extracted config)))) - (testing "Extracts config from :auth-identity :metadata" (let [config {:client-id "test" :client-secret "secret" @@ -118,7 +107,6 @@ :other-key "ignored"} extracted (oidc.common/extract-oidc-config request)] (is (= extracted config)))) - (testing "Extracts config from direct request keys" (let [request {:client-id "test" :client-secret "secret" @@ -131,7 +119,6 @@ :issuer-uri "https://example.com" :redirect-uri "https://metabase.com/callback"} extracted)))) - (testing "Prefers :oidc-config over :auth-identity" (let [oidc-config {:client-id "from-oidc-config"} auth-identity-config {:client-id "from-auth-identity"} @@ -139,14 +126,12 @@ :auth-identity {:metadata auth-identity-config}} extracted (oidc.common/extract-oidc-config request)] (is (= "from-oidc-config" (:client-id extracted))))) - (testing "Prefers :auth-identity over direct keys" (let [auth-identity-config {:client-id "from-auth-identity"} request {:auth-identity {:metadata auth-identity-config} :client-id "from-direct-keys"} extracted (oidc.common/extract-oidc-config request)] (is (= "from-auth-identity" (:client-id extracted))))) - (testing "Returns nil when no config found" (let [request {:other-key "value"} extracted (oidc.common/extract-oidc-config request)] @@ -163,7 +148,6 @@ (is (= "access-token-123" (:access-token parsed))) (is (= "refresh-token-456" (:refresh-token parsed))) (is (= 3600 (:expires-in parsed))))) - (testing "Parses token response with missing optional fields" (let [response-body {:id_token "eyJhbGciOiJSUzI1NiJ9..." :access_token "access-token-123"} @@ -182,7 +166,6 @@ (is (= "auth-code-123" (:code result))) (is (= "state-token-456" (:state result))) (is (nil? (:error result))))) - (testing "Error response from provider" (let [params {:error "access_denied" :error_description "User denied access"} @@ -190,19 +173,16 @@ (is (false? (:valid? result))) (is (= "access_denied" (get-in result [:error :code]))) (is (= "User denied access" (get-in result [:error :description]))))) - (testing "Missing authorization code" (let [params {:state "state-token"} result (oidc.common/validate-callback-params params)] (is (false? (:valid? result))) (is (= :missing_code (get-in result [:error :code]))))) - (testing "Missing state parameter" (let [params {:code "auth-code"} result (oidc.common/validate-callback-params params)] (is (false? (:valid? result))) (is (= :missing_state (get-in result [:error :code]))))) - (testing "Empty params map" (let [params {} result (oidc.common/validate-callback-params params)] diff --git a/test/metabase/sso/oidc/discovery_test.clj b/test/metabase/sso/oidc/discovery_test.clj index 3e54ac0c23f9..22ef2d271805 100644 --- a/test/metabase/sso/oidc/discovery_test.clj +++ b/test/metabase/sso/oidc/discovery_test.clj @@ -11,18 +11,15 @@ (let [config {:discovery-document {:authorization_endpoint "https://provider.com/authorize"}} endpoint (oidc.discovery/get-authorization-endpoint config)] (is (= "https://provider.com/authorize" endpoint)))) - (testing "Gets authorization endpoint from manual config" (let [config {:authorization-endpoint "https://provider.com/manual/authorize"} endpoint (oidc.discovery/get-authorization-endpoint config)] (is (= "https://provider.com/manual/authorize" endpoint)))) - (testing "Prefers discovery document over manual config" (let [config {:discovery-document {:authorization_endpoint "https://provider.com/discovery/authorize"} :authorization-endpoint "https://provider.com/manual/authorize"} endpoint (oidc.discovery/get-authorization-endpoint config)] (is (= "https://provider.com/discovery/authorize" endpoint)))) - (testing "Returns nil when not found" (let [config {} endpoint (oidc.discovery/get-authorization-endpoint config)] @@ -33,18 +30,15 @@ (let [config {:discovery-document {:token_endpoint "https://provider.com/token"}} endpoint (oidc.discovery/get-token-endpoint config)] (is (= "https://provider.com/token" endpoint)))) - (testing "Gets token endpoint from manual config" (let [config {:token-endpoint "https://provider.com/manual/token"} endpoint (oidc.discovery/get-token-endpoint config)] (is (= "https://provider.com/manual/token" endpoint)))) - (testing "Prefers discovery document over manual config" (let [config {:discovery-document {:token_endpoint "https://provider.com/discovery/token"} :token-endpoint "https://provider.com/manual/token"} endpoint (oidc.discovery/get-token-endpoint config)] (is (= "https://provider.com/discovery/token" endpoint)))) - (testing "Returns nil when not found" (let [config {} endpoint (oidc.discovery/get-token-endpoint config)] @@ -55,18 +49,15 @@ (let [config {:discovery-document {:jwks_uri "https://provider.com/jwks"}} uri (oidc.discovery/get-jwks-uri config)] (is (= "https://provider.com/jwks" uri)))) - (testing "Gets JWKS URI from manual config" (let [config {:jwks-uri "https://provider.com/manual/jwks"} uri (oidc.discovery/get-jwks-uri config)] (is (= "https://provider.com/manual/jwks" uri)))) - (testing "Prefers discovery document over manual config" (let [config {:discovery-document {:jwks_uri "https://provider.com/discovery/jwks"} :jwks-uri "https://provider.com/manual/jwks"} uri (oidc.discovery/get-jwks-uri config)] (is (= "https://provider.com/discovery/jwks" uri)))) - (testing "Returns nil when not found" (let [config {} uri (oidc.discovery/get-jwks-uri config)] @@ -154,13 +145,11 @@ ;; First call should fetch (oidc.discovery/discover-oidc-configuration "https://github.com") (is (= 1 @fetch-count)) - ;; Manually expire the cache entry by setting fetched-at to 25 hours ago (TTL is 24h) (let [twenty-five-hours-ago (t/to-millis-from-epoch (t/minus (t/instant) (t/hours 25)))] (swap! @#'oidc.discovery/discovery-cache assoc "https://github.com" {:document test-discovery-doc :fetched-at twenty-five-hours-ago})) - ;; Next call should re-fetch because cache is expired (oidc.discovery/discover-oidc-configuration "https://github.com") (is (= 2 @fetch-count)))))) @@ -176,10 +165,8 @@ ;; Populate cache (oidc.discovery/discover-oidc-configuration "https://microsoft.com") (is (= 1 @fetch-count)) - ;; Invalidate the cache entry (oidc.discovery/invalidate-cache! "https://microsoft.com") - ;; Next call should re-fetch (oidc.discovery/discover-oidc-configuration "https://microsoft.com") (is (= 2 @fetch-count)))))) @@ -195,10 +182,8 @@ ;; Populate cache without trailing slash (oidc.discovery/discover-oidc-configuration "https://apple.com") (is (= 1 @fetch-count)) - ;; Invalidate with trailing slash (should still work) (oidc.discovery/invalidate-cache! "https://apple.com/") - ;; Next call should re-fetch (oidc.discovery/discover-oidc-configuration "https://apple.com") (is (= 2 @fetch-count)))))) @@ -212,19 +197,15 @@ (testing "Rejects internal addresses (localhost)" (oidc.discovery/clear-cache!) (is (nil? (oidc.discovery/discover-oidc-configuration "http://localhost/oidc")))) - (testing "Rejects internal addresses (127.0.0.1)" (oidc.discovery/clear-cache!) (is (nil? (oidc.discovery/discover-oidc-configuration "http://127.0.0.1/oidc")))) - (testing "Rejects cloud metadata endpoint" (oidc.discovery/clear-cache!) (is (nil? (oidc.discovery/discover-oidc-configuration "http://169.254.169.254/metadata")))) - (testing "Rejects private network addresses (192.168.x.x)" (oidc.discovery/clear-cache!) (is (nil? (oidc.discovery/discover-oidc-configuration "http://192.168.1.1/oidc")))) - (testing "Rejects private network addresses (10.x.x.x)" (oidc.discovery/clear-cache!) (is (nil? (oidc.discovery/discover-oidc-configuration "http://10.0.0.1/oidc"))))))) @@ -238,7 +219,6 @@ (is (thrown-with-msg? clojure.lang.ExceptionInfo #"address not allowed by network restrictions" (oidc.discovery/get-token-endpoint config)))))) - (testing "get-token-endpoint allows all hosts when oidc-allowed-networks is :allow-all" (mt/with-temporary-setting-values [oidc-allowed-networks :allow-all] (let [config {:discovery-document {:token_endpoint "https://provider.example.com/token"}}] diff --git a/test/metabase/sso/oidc/http_test.clj b/test/metabase/sso/oidc/http_test.clj index 673fd7ff342a..c41f592ebac5 100644 --- a/test/metabase/sso/oidc/http_test.clj +++ b/test/metabase/sso/oidc/http_test.clj @@ -12,17 +12,14 @@ (is (thrown-with-msg? clojure.lang.ExceptionInfo #"address not allowed by network restrictions" (oidc.http/oidc-get "http://localhost/path")))) - (testing "Rejects cloud metadata endpoint" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"address not allowed by network restrictions" (oidc.http/oidc-get "http://169.254.169.254/latest/meta-data/")))) - (testing "Rejects private network addresses" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"address not allowed by network restrictions" (oidc.http/oidc-get "http://192.168.1.1/path")))))) - (testing "oidc-get allows requests when oidc-allowed-networks is :allow-all" (mt/with-temporary-setting-values [oidc-allowed-networks :allow-all] (with-redefs [http/get (fn [_url _opts] {:status 200 :body {:ok true}})] @@ -37,13 +34,11 @@ #"address not allowed by network restrictions" (oidc.http/oidc-post "http://169.254.169.254/latest/meta-data/" {:form-params {:grant_type "client_credentials"}})))) - (testing "Rejects loopback" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"address not allowed by network restrictions" (oidc.http/oidc-post "http://127.0.0.1/token" {:form-params {:grant_type "client_credentials"}})))))) - (testing "oidc-post allows requests when oidc-allowed-networks is :allow-all" (mt/with-temporary-setting-values [oidc-allowed-networks :allow-all] (with-redefs [http/post (fn [_url _opts] {:status 200 :body {:access_token "tok"}})] diff --git a/test/metabase/sso/oidc/schema_test.clj b/test/metabase/sso/oidc/schema_test.clj index 839c9a1c6e30..770344339bca 100644 --- a/test/metabase/sso/oidc/schema_test.clj +++ b/test/metabase/sso/oidc/schema_test.clj @@ -10,7 +10,6 @@ :issuer-uri "https://example.com" :redirect-uri "https://metabase.example.com/auth/oidc/callback"}] (is (true? (oidc.schema/discovery-based? config))))) - (testing "Configuration is NOT discovery-based when authorization-endpoint present" (let [config {:client-id "test-client-id" :client-secret "test-client-secret" @@ -18,7 +17,6 @@ :redirect-uri "https://metabase.example.com/auth/oidc/callback" :authorization-endpoint "https://example.com/authorize"}] (is (false? (oidc.schema/discovery-based? config))))) - (testing "Configuration is NOT discovery-based when token-endpoint present" (let [config {:client-id "test-client-id" :client-secret "test-client-secret" @@ -26,7 +24,6 @@ :redirect-uri "https://metabase.example.com/auth/oidc/callback" :token-endpoint "https://example.com/token"}] (is (false? (oidc.schema/discovery-based? config))))) - (testing "Configuration is NOT discovery-based when jwks-uri present" (let [config {:client-id "test-client-id" :client-secret "test-client-secret" @@ -34,7 +31,6 @@ :redirect-uri "https://metabase.example.com/auth/oidc/callback" :jwks-uri "https://example.com/jwks"}] (is (false? (oidc.schema/discovery-based? config))))) - (testing "Configuration is NOT discovery-based when userinfo-endpoint present" (let [config {:client-id "test-client-id" :client-secret "test-client-secret" diff --git a/test/metabase/sso/oidc/state_test.clj b/test/metabase/sso/oidc/state_test.clj index 28a22a3fa74a..de40c0b8d809 100644 --- a/test/metabase/sso/oidc/state_test.clj +++ b/test/metabase/sso/oidc/state_test.clj @@ -52,43 +52,34 @@ (is (true? (oidc.state/valid-redirect-url? "/dashboard/123" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "/path?query=value" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "/path#fragment" "https://example.com")))) - (testing "rejects protocol-relative URLs (potential open redirect)" (is (false? (oidc.state/valid-redirect-url? "//evil.com" "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "//evil.com/path" "https://example.com"))))) - (testing "absolute URLs" (testing "accepts same-origin URLs" (is (true? (oidc.state/valid-redirect-url? "https://example.com/" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "https://example.com/dashboard" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "https://example.com:443/path" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "http://example.com:80/path" "http://example.com")))) - (testing "accepts same-origin with explicit default ports" (is (true? (oidc.state/valid-redirect-url? "https://example.com:443/" "https://example.com/"))) (is (true? (oidc.state/valid-redirect-url? "http://example.com:80/" "http://example.com/")))) - (testing "accepts same-origin with non-default ports" (is (true? (oidc.state/valid-redirect-url? "https://example.com:8443/path" "https://example.com:8443"))) (is (true? (oidc.state/valid-redirect-url? "http://example.com:3000/" "http://example.com:3000/")))) - (testing "rejects different origins" (is (false? (oidc.state/valid-redirect-url? "https://evil.com/" "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "https://evil.com/path" "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "https://subdomain.example.com/" "https://example.com")))) - (testing "rejects different schemes" (is (false? (oidc.state/valid-redirect-url? "http://example.com/" "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "https://example.com/" "http://example.com")))) - (testing "rejects different ports" (is (false? (oidc.state/valid-redirect-url? "https://example.com:8443/" "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "https://example.com/" "https://example.com:8443"))))) - (testing "case insensitivity" (is (true? (oidc.state/valid-redirect-url? "https://EXAMPLE.COM/path" "https://example.com"))) (is (true? (oidc.state/valid-redirect-url? "HTTPS://example.com/path" "https://example.com")))) - (testing "invalid inputs" (is (false? (oidc.state/valid-redirect-url? nil "https://example.com"))) (is (false? (oidc.state/valid-redirect-url? "" "https://example.com"))) @@ -120,7 +111,6 @@ (is (number? (:created-at state-map))) (is (number? (:expires-at state-map))) (is (> (:expires-at state-map) (:created-at state-map))))) - (testing "includes browser-id when provided" (let [state-map (oidc.state/create-oidc-state {:state "csrf-token" :nonce "nonce-value" @@ -128,14 +118,12 @@ :provider :slack-connect :browser-id "device-uuid"})] (is (= "device-uuid" (:browser-id state-map))))) - (testing "omits browser-id when not provided" (let [state-map (oidc.state/create-oidc-state {:state "csrf-token" :nonce "nonce-value" :redirect "/dashboard" :provider :slack-connect})] (is (not (contains? state-map :browser-id))))) - (testing "uses custom ttl-ms when provided" (let [before (now-ms) state-map (oidc.state/create-oidc-state {:state "csrf-token" @@ -147,21 +135,18 @@ ;; expires-at should be ~5 minutes from created-at (is (>= (:expires-at state-map) (+ before 300000))) (is (<= (:expires-at state-map) (+ after 300000))))) - (testing "converts provider keyword to string" (let [state-map (oidc.state/create-oidc-state {:state "csrf-token" :nonce "nonce-value" :redirect "/dashboard" :provider :my-provider})] (is (= "my-provider" (:provider state-map))))) - (testing "accepts same-origin absolute redirect URL" (let [state-map (oidc.state/create-oidc-state {:state "csrf-token" :nonce "nonce-value" :redirect "https://metabase.example.com/dashboard" :provider :slack-connect})] (is (= "https://metabase.example.com/dashboard" (:redirect state-map))))) - (testing "throws on missing required fields" (is (thrown? AssertionError (oidc.state/create-oidc-state {:nonce "nonce" @@ -190,7 +175,6 @@ :nonce "nonce-value" :redirect "https://evil.com/steal-session" :provider :slack-connect})))) - (testing "throws on protocol-relative URL" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -199,7 +183,6 @@ :nonce "nonce-value" :redirect "//evil.com/path" :provider :slack-connect})))) - (testing "throws on different subdomain" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -208,7 +191,6 @@ :nonce "nonce-value" :redirect "https://other.example.com/path" :provider :slack-connect})))) - (testing "throws on javascript: URL" (is (thrown-with-msg? clojure.lang.ExceptionInfo @@ -234,7 +216,6 @@ (is (string? encrypted)) (is (not= (pr-str original) encrypted)) (is (= original decrypted)))) - (testing "encrypted output is URL-safe base64" (let [state-map {:state "csrf-token" :nonce "nonce-value" @@ -329,7 +310,6 @@ (is (= "nonce-value" (:nonce retrieved))) (is (= "/dashboard" (:redirect retrieved))) (is (= "slack-connect" (:provider retrieved)))))) - (with-test-encryption! (testing "sets correct cookie attributes for HTTPS" (let [request {:scheme :https} @@ -400,7 +380,6 @@ (is (= "https://slack.com/oauth/authorize?..." (get-in response [:headers "Location"]))) ;; Should have state cookie (is (string? (get-in response [:cookies "metabase.OIDC_STATE" :value])))))) - (with-test-encryption! (testing "rejects external redirect URL (open redirect protection)" (let [auth-result {:redirect-url "https://slack.com/oauth/authorize?..." @@ -430,7 +409,6 @@ (is (true? (:valid? result))) (is (= "test-nonce" (:nonce result))) (is (= "/dashboard" (:redirect result))))) - (testing "rejects mismatched state (CSRF protection)" (let [request {:scheme :https} response (oidc.state/set-oidc-state-cookie {} request {:state "correct-state" @@ -442,7 +420,6 @@ result (oidc.state/validate-oidc-callback callback "wrong-state" :slack-connect)] (is (false? (:valid? result))) (is (= :state-mismatch (:error result))))) - (testing "rejects wrong provider" (let [request {:scheme :https} response (oidc.state/set-oidc-state-cookie {} request {:state "state" @@ -454,7 +431,6 @@ result (oidc.state/validate-oidc-callback callback "state" :other-provider)] (is (false? (:valid? result))) (is (= :invalid-or-expired-state (:error result))))) - (testing "rejects missing cookie" (let [result (oidc.state/validate-oidc-callback {:cookies {}} "state" :slack-connect)] (is (false? (:valid? result))) diff --git a/test/metabase/sso/oidc/tokens_test.clj b/test/metabase/sso/oidc/tokens_test.clj index 9e98356242f3..e62619da4915 100644 --- a/test/metabase/sso/oidc/tokens_test.clj +++ b/test/metabase/sso/oidc/tokens_test.clj @@ -310,13 +310,11 @@ h0ccjghRm1/Az8L/HL+gdQmtY0NdB4Ml2mZHCVsPYf5WzIirTpjY0EzKDA== ;; First call should fetch (oidc.tokens/get-jwks "https://github.com/jwks") (is (= 1 @fetch-count)) - ;; Manually expire the cache entry by setting fetched-at to 2 hours ago (let [two-hours-ago (t/to-millis-from-epoch (t/minus (t/instant) (t/hours 2)))] (swap! @#'oidc.tokens/jwks-cache assoc "https://github.com/jwks" {:jwks test-jwks :fetched-at two-hours-ago})) - ;; Next call should re-fetch because cache is expired (oidc.tokens/get-jwks "https://github.com/jwks") (is (= 2 @fetch-count)))))) @@ -332,10 +330,8 @@ h0ccjghRm1/Az8L/HL+gdQmtY0NdB4Ml2mZHCVsPYf5WzIirTpjY0EzKDA== ;; Populate cache (oidc.tokens/get-jwks "https://provider.com/jwks") (is (= 1 @fetch-count)) - ;; Invalidate the cache entry (oidc.tokens/invalidate-jwks-cache! "https://provider.com/jwks") - ;; Next call should re-fetch (oidc.tokens/get-jwks "https://provider.com/jwks") (is (= 2 @fetch-count)))))) @@ -348,19 +344,15 @@ h0ccjghRm1/Az8L/HL+gdQmtY0NdB4Ml2mZHCVsPYf5WzIirTpjY0EzKDA== (testing "Rejects internal addresses (localhost)" (oidc.tokens/clear-jwks-cache!) (is (nil? (oidc.tokens/get-jwks "http://localhost/jwks")))) - (testing "Rejects internal addresses (127.0.0.1)" (oidc.tokens/clear-jwks-cache!) (is (nil? (oidc.tokens/get-jwks "http://127.0.0.1/jwks")))) - (testing "Rejects cloud metadata endpoint" (oidc.tokens/clear-jwks-cache!) (is (nil? (oidc.tokens/get-jwks "http://169.254.169.254/jwks")))) - (testing "Rejects private network addresses (192.168.x.x)" (oidc.tokens/clear-jwks-cache!) (is (nil? (oidc.tokens/get-jwks "http://192.168.1.1/jwks")))) - (testing "Rejects private network addresses (10.x.x.x)" (oidc.tokens/clear-jwks-cache!) (is (nil? (oidc.tokens/get-jwks "http://10.0.0.1/jwks"))))))) diff --git a/test/metabase/sso/providers/oidc_test.clj b/test/metabase/sso/providers/oidc_test.clj index d8f2bfbd9275..1a5c45bb9ab7 100644 --- a/test/metabase/sso/providers/oidc_test.clj +++ b/test/metabase/sso/providers/oidc_test.clj @@ -44,7 +44,6 @@ (is (str/includes? (:redirect-url result) "https://provider.example.com/authorize")) (is (str/includes? (:redirect-url result) "client_id=test-client-id")) (is (str/includes? (:redirect-url result) "response_type=code"))))) - (testing "Uses manual endpoints when provided" (let [config (assoc test-config :authorization-endpoint "https://provider.example.com/manual/authorize") @@ -52,7 +51,6 @@ result (provider/authenticate :provider/oidc request)] (is (= :redirect (:success? result))) (is (str/includes? (:redirect-url result) "https://provider.example.com/manual/authorize")))) - (testing "Includes custom scopes in authorization URL" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] test-discovery-doc)] @@ -61,7 +59,6 @@ result (provider/authenticate :provider/oidc request)] (is (= :redirect (:success? result))) (is (str/includes? (:redirect-url result) "scope=openid%20email%20profile%20groups"))))) - (testing "Returns error when authorization endpoint not found" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] nil)] @@ -78,14 +75,12 @@ result (provider/authenticate :provider/oidc request)] (is (false? (:success? result))) (is (= :invalid-callback (:error result))))) - (testing "Returns error when code is missing" (let [request {:oidc-config test-config :state "some-state"} result (provider/authenticate :provider/oidc request)] (is (false? (:success? result))) (is (= :invalid-callback (:error result))))) - (testing "Returns error when state is missing" (let [request {:oidc-config test-config :code "some-code"} @@ -107,7 +102,6 @@ result (provider/authenticate :provider/oidc request)] (is (false? (:success? result))) (is (= :token-exchange-failed (:error result)))))) - (testing "Returns error when token response missing id_token" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] test-discovery-doc) @@ -196,7 +190,6 @@ (is (= "user123" (get-in result [:user-data :provider-id]))) (is (= :oidc (get-in result [:user-data :sso_source]))) (is (= "user123" (:provider-id result)))))) - (testing "Successfully authenticates with minimal claims" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] test-discovery-doc) @@ -262,14 +255,12 @@ (let [request {:oidc-config test-config} result (provider/authenticate :provider/oidc request)] (is (= :redirect (:success? result)))))) - (testing "Extracts config from :auth-identity metadata" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] test-discovery-doc)] (let [request {:auth-identity {:metadata test-config}} result (provider/authenticate :provider/oidc request)] (is (= :redirect (:success? result)))))) - (testing "Extracts config from direct request keys" (with-redefs [oidc.discovery/discover-oidc-configuration (fn [_issuer] test-discovery-doc)] @@ -280,6 +271,5 @@ (deftest provider-hierarchy-test (testing "OIDC provider derives from base provider" (is (isa? :provider/oidc ::provider/provider))) - (testing "OIDC provider derives from create-user-if-not-exists" (is (isa? :provider/oidc ::provider/create-user-if-not-exists)))) diff --git a/test/metabase/sso/providers/slack_connect_test.clj b/test/metabase/sso/providers/slack_connect_test.clj index 2e4615d56604..7eef3cc40419 100644 --- a/test/metabase/sso/providers/slack_connect_test.clj +++ b/test/metabase/sso/providers/slack_connect_test.clj @@ -28,7 +28,6 @@ (deftest ^:parallel provider-hierarchy-test (testing "Slack Connect provider derives from OIDC provider" (is (isa? :provider/slack-connect :provider/oidc))) - (testing "Slack Connect provider derives from create-user-if-not-exists" (is (isa? :provider/slack-connect ::provider/create-user-if-not-exists)))) @@ -46,7 +45,6 @@ (is (= "https://slack.com" (:issuer-uri config))) (is (= ["openid" "profile" "email"] (:scopes config))) (is (= "https://metabase.example.com/auth/sso/slack-connect/callback" (:redirect-uri config)))))) - (testing "Returns nil when client ID is missing" (mt/with-temporary-setting-values [slack-connect-client-id nil @@ -54,7 +52,6 @@ (let [request {:redirect-uri "https://metabase.example.com/auth/sso/slack-connect/callback"} config (#'metabase.sso.providers.slack-connect/build-slack-oidc-config request)] (is (nil? config))))) - (testing "Returns nil when client secret is missing" (mt/with-temporary-setting-values [slack-connect-client-id "test-client-id" @@ -80,7 +77,6 @@ (is (= "https://example.com/team.png" (get slack-attrs "slack-team-image"))) (is (= "true" (get slack-attrs "slack-email-verified"))) (is (= "en-US" (get slack-attrs "slack-locale")))))) - (testing "Handles missing optional claims gracefully" (mt/with-temporary-setting-values [slack-connect-attribute-team-id "https://slack.com/team_id"] @@ -398,13 +394,11 @@ [slack-connect-client-id "test-client-id" slack-connect-client-secret "test-secret"] (is (true? (sso-settings/slack-connect-configured))))) - (testing "Returns false when client ID is missing" (mt/with-temporary-setting-values [slack-connect-client-id nil slack-connect-client-secret "test-secret"] (is (false? (sso-settings/slack-connect-configured))))) - (testing "Returns false when client secret is missing" (mt/with-temporary-setting-values [slack-connect-client-id "test-client-id" @@ -418,14 +412,12 @@ slack-connect-client-secret "test-secret" slack-connect-enabled true] (is (true? (sso-settings/slack-connect-enabled))))) - (testing "Returns false when configured but not enabled" (mt/with-temporary-setting-values [slack-connect-client-id "test-client-id" slack-connect-client-secret "test-secret" slack-connect-enabled false] (is (false? (sso-settings/slack-connect-enabled))))) - (testing "Returns false when not configured even if enabled is true" (mt/with-temporary-setting-values [slack-connect-client-id nil @@ -441,7 +433,6 @@ (is (= "sso" (sso-settings/slack-connect-authentication-mode))) (sso-settings/slack-connect-authentication-mode! "link-only") (is (= "link-only" (sso-settings/slack-connect-authentication-mode))))) - (testing "Rejects invalid authentication modes" (is (thrown-with-msg? clojure.lang.ExceptionInfo diff --git a/test/metabase/sso/settings_test.clj b/test/metabase/sso/settings_test.clj index 010feccdbd1b..b1e27302b989 100644 --- a/test/metabase/sso/settings_test.clj +++ b/test/metabase/sso/settings_test.clj @@ -17,7 +17,6 @@ (with-redefs [ldap/test-current-ldap-details (constantly {:status :SUCCESS})] (sso.settings/ldap-enabled! true) (is (sso.settings/ldap-enabled)) - (sso.settings/ldap-enabled! false) (is (not (sso.settings/ldap-enabled)))))))) diff --git a/test/metabase/sync/analyze/classify_test.clj b/test/metabase/sync/analyze/classify_test.clj index f41873d3b9ac..f30a5ac2bfd5 100644 --- a/test/metabase/sync/analyze/classify_test.clj +++ b/test/metabase/sync/analyze/classify_test.clj @@ -115,14 +115,11 @@ :semantic_type nil :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (classify/classify-fields! table) - (let [name-fields (t2/select :model/Field :table_id (u/the-id table) :semantic_type :type/Name)] (is (= 1 (count name-fields))) - ;; Should be the first name field encountered, but we can't assume a specific ordering (is (#{"lastName" "fullName" "firstName"} (:name (first name-fields)))))))) @@ -151,7 +148,6 @@ :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] (classify/classify-fields! table) - (let [name-fields (t2/select :model/Field :table_id (u/the-id table) :semantic_type :type/Name)] @@ -159,7 +155,6 @@ ;; The original fields should keep their type/Name (is (some #(= "firstName" (:name %)) name-fields)) (is (some #(= "lastName" (:name %)) name-fields)) - (is (not= :type/Name (:semantic_type (t2/select-one :model/Field :id (u/the-id field))))))))) (deftest no-name-field-candidates-test @@ -169,7 +164,6 @@ :model/Field _ {:name "id" :base_type :type/Integer :table_id (u/the-id table)} :model/Field _ {:name "value" :base_type :type/Float :table_id (u/the-id table)} :model/Field _ {:name "timestamp" :base_type :type/DateTime :table_id (u/the-id table)}] - (is (not= ::thrown (try (classify/classify-fields! table) (catch Throwable _ ::thrown)))) (let [name-fields (t2/select :model/Field :table_id (u/the-id table) @@ -191,11 +185,9 @@ :semantic_type nil :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - ;; Run classification twice (classify/classify-fields! table) (classify/classify-fields! table) - (let [name-fields (t2/select :model/Field :table_id (u/the-id table) :semantic_type :type/Name)] @@ -216,11 +208,9 @@ :preview_display true :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (with-redefs [classifiers.no-preview-display/infer-no-preview-display (fn [field _] (assoc field :preview_display false))] (classify/classify-fields! table)) - (let [updated-field (t2/select-one :model/Field :id (u/the-id field))] (is (not= :type/Name (:semantic_type updated-field))) (is (false? (:preview_display updated-field))))))) @@ -236,9 +226,7 @@ :semantic_type nil :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (classify/classify-fields! table) - (let [updated-field (t2/select-one :model/Field :id (u/the-id new-name-field))] (is (= :type/Name (:semantic_type updated-field))))))) @@ -257,9 +245,7 @@ :active true :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (classify/classify-fields! table) - (let [updated-field (t2/select-one :model/Field :id (u/the-id potential-name)) existing-field (t2/select-one :model/Field :id (u/the-id active-field))] (is (not= :type/Name (:semantic_type updated-field))) @@ -278,9 +264,7 @@ :visibility_type "normal" :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (classify/classify-fields! table) - (let [updated-field (t2/select-one :model/Field :id (u/the-id new-name))] (is (= :type/Name (:semantic_type updated-field))))))) @@ -303,8 +287,6 @@ :visibility_type "normal" :fingerprint_version i/*latest-fingerprint-version* :last_analyzed nil}] - (classify/classify-fields! table) - (let [updated-field (t2/select-one :model/Field :id (u/the-id new-name))] (is (= :type/Name (:semantic_type updated-field))))))) diff --git a/test/metabase/sync/analyze_test.clj b/test/metabase/sync/analyze_test.clj index 892f69414ca8..cde0b48f6d2c 100644 --- a/test/metabase/sync/analyze_test.clj +++ b/test/metabase/sync/analyze_test.clj @@ -261,7 +261,6 @@ (let [field (mi/instance :model/Field {:base_type :type/Integer :name "foo_type"}) fingerprint (fn [c] {:global {:distinct-count c :nil% 0}}) threshold classifiers.category/category-cardinality-threshold] - (are [card] (-> @@ -272,7 +271,6 @@ (dec threshold) threshold (inc threshold)) - (is (not-category (classifiers.name/infer-and-assoc-semantic-type-by-name field {})))))) (deftest classify-bool-values-test diff --git a/test/metabase/sync/field_values_test.clj b/test/metabase/sync/field_values_test.clj index 4ec474258b00..148b4eeb3fec 100644 --- a/test/metabase/sync/field_values_test.clj +++ b/test/metabase/sync/field_values_test.clj @@ -30,7 +30,6 @@ (field-values/get-or-create-full-field-values! (t2/select-one :model/Field (mt/id :venues :price))) ;; Reset them to values that should get updated during sync (t2/update! :model/FieldValues :field_id (mt/id :venues :price) {:values [10 20 30 40]}) - ;; sync to make sure the field values are filled (sync-database!' "update-field-values" (data/db)) (is (= [1 2 3 4] @@ -102,7 +101,6 @@ :type :advanced :hash_key "random-key" :last_used_at (t/instant)}) - (is (= (repeat 2 {:errors 0, :created 0, :updated 1, :deleted 0}) (sync-database!' "update-field-values" (data/db))))) (is (= [1 2 3 4] (venues-price-field-values))))) @@ -129,30 +127,30 @@ :hash_key "random-hash" :created_at expired-created-at :updated_at expired-created-at} - ;; expired linked-filter fieldvalues + ;; expired linked-filter fieldvalues {:field_id field-id :type "advanced" :hash_key "random-hash" :created_at expired-created-at :updated_at expired-created-at} - ;; valid sandbox fieldvalues + ;; valid sandbox fieldvalues {:field_id field-id :type "advanced" :hash_key "random-hash" :created_at now :updated_at now} - ;; valid linked-filter fieldvalues + ;; valid linked-filter fieldvalues {:field_id field-id :type "advanced" :hash_key "random-hash" :created_at now :updated_at now} - ;; old full fieldvalues + ;; old full fieldvalues {:field_id field-id :type "full" :created_at expired-created-at :updated_at expired-created-at} - ;; new full fieldvalues + ;; new full fieldvalues {:field_id field-id :type "full" :created_at now @@ -175,26 +173,22 @@ (testing "has_field_values should be auto-list" (is (= :auto-list (t2/select-one-fn :has_field_values :model/Field :id (mt/id :blueberries_consumed :str))))) - (testing "... and it should also have some FieldValues" (is (= {:values (one-off-dbs/range-str 50) :human_readable_values [] :has_more_values false} (into {} (t2/select-one [:model/FieldValues :values :human_readable_values :has_more_values] :field_id (mt/id :blueberries_consumed :str)))))) - ;; Manually add an advanced field values to test whether or not it got deleted later (t2/insert! :model/FieldValues {:field_id (mt/id :blueberries_consumed :str) :type :advanced :hash_key "random-key"}) - (testing "We mark the field values as :has_more_values when it grows too big." ;; now insert enough bloobs to put us over the limit and re-sync. (one-off-dbs/insert-rows-and-sync! (one-off-dbs/range-str 50 (+ 100 analyze/auto-list-cardinality-threshold))) (testing "has_field_values stay auto-list." (is (= :auto-list (t2/select-one-fn :has_field_values :model/Field :id (mt/id :blueberries_consumed :str))))) - (testing "its FieldValues be limited." (is (=? {:values #(>= analyze/auto-list-cardinality-threshold (count %)) :has_more_values true} @@ -210,13 +204,11 @@ (testing "has_field_values should be auto-list" (is (= :auto-list (t2/select-one-fn :has_field_values :model/Field :id (mt/id :blueberries_consumed :str))))) - (testing "... and it should also have some FieldValues" (is (= {:values [(str/join (repeat 50 "A"))] :human_readable_values []} (into {} (t2/select-one [:model/FieldValues :values :human_readable_values] :field_id (mt/id :blueberries_consumed :str)))))) - (testing "If the total length of all values exceeded the length threshold, it should get stay as auto list but be limitted" (one-off-dbs/insert-rows-and-sync! [(str/join (repeat 10 "B")) (str/join (repeat (+ 100 field-values/*total-max-length*) "X")) @@ -224,7 +216,6 @@ (testing "has_field_values should have been set to nil." (is (= :auto-list (t2/select-one-fn :has_field_values :model/Field :id (mt/id :blueberries_consumed :str))))) - (testing "Field values before the limit is reached are added" (is (=? {:has_more_values true :values [(str/join (repeat 50 "A")) @@ -277,7 +268,6 @@ (testing "has_more_values should initially be false" (is (= false (t2/select-one-fn :has_more_values :model/FieldValues :field_id (mt/id :blueberries_consumed :str))))) - (testing "insert a row with the value length exceeds our length limit\n" (one-off-dbs/insert-rows-and-sync! [(str/join (repeat (+ 100 field-values/*total-max-length*) "A"))]) (testing "has_field_values shouldn't change and has_more_values should be true" diff --git a/test/metabase/sync/sync_metadata/comments_test.clj b/test/metabase/sync/sync_metadata/comments_test.clj index e658553f9d52..b47046928172 100644 --- a/test/metabase/sync/sync_metadata/comments_test.clj +++ b/test/metabase/sync/sync_metadata/comments_test.clj @@ -117,7 +117,7 @@ added-comment (mt/random-name) dbdef (basic-table table-name nil)] (mt/dataset dbdef - ;; create the comment + ;; create the comment (jdbc/execute! (sql-jdbc.conn/db->pooled-connection-spec (mt/db)) [(sql.tx/standalone-table-comment-sql driver/*driver* diff --git a/test/metabase/sync/sync_metadata/fields/our_metadata_test.clj b/test/metabase/sync/sync_metadata/fields/our_metadata_test.clj index 6c228ad374d8..c0d875e3c44a 100644 --- a/test/metabase/sync/sync_metadata/fields/our_metadata_test.clj +++ b/test/metabase/sync/sync_metadata/fields/our_metadata_test.clj @@ -107,7 +107,6 @@ :json-unfolding false :database-is-auto-increment false :preview-display true}}}}}} - (let [transactions-table-id (u/the-id (t2/select-one-pk :model/Table :db_id (u/the-id db), :name "transactions")) remove-ids-and-nil-vals (partial walk/postwalk #(if-not (map? %) % diff --git a/test/metabase/sync/sync_metadata/fields/sync_instances_test.clj b/test/metabase/sync/sync_metadata/fields/sync_instances_test.clj index 18b64bac4e17..53c0bcf6f61c 100644 --- a/test/metabase/sync/sync_metadata/fields/sync_instances_test.clj +++ b/test/metabase/sync/sync_metadata/fields/sync_instances_test.clj @@ -121,7 +121,6 @@ :table_id transactions-table-id :parent_id details-field-id :active true)))] - ;; now sync again. (sync-metadata/sync-db-metadata! db) ;; field should become inactive diff --git a/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj b/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj index a0b9468f4ed4..10ec2daec6e4 100644 --- a/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj +++ b/test/metabase/sync/sync_metadata/fields/sync_metadata_test.clj @@ -174,7 +174,6 @@ (updates-that-will-be-performed! (merge default-metadata {:database-partitioned false}) (merge default-metadata {:database-partitioned nil :id 1}))))) - (testing "flip the state" (is (= [["Field" 1 {:database_partitioned false}]] (updates-that-will-be-performed! @@ -202,7 +201,6 @@ :database-required false :database-is-auto-increment false :json-unfolding false})))) - (testing (str "if `database-type` comes back as `nil` and was already saved in application DB as `NULL` no changes " "should be made") (is (= [] @@ -297,7 +295,6 @@ {:id 1 :base-type :type/Integer :effective-type :type/Integer})))) - (testing "and sync will re-fingerprint and analyze this field" (mt/with-temp-test-data [["table" [{:field-name "field" @@ -312,7 +309,7 @@ :base_type :type/Text :effective_type :type/Text} original-field))) - ;; drop the column and create a new one with the same name + ;; drop the column and create a new one with the same name (sql-jdbc.execute/do-with-connection-with-options :h2 (mt/db) diff --git a/test/metabase/sync/sync_metadata/fields_test.clj b/test/metabase/sync/sync_metadata/fields_test.clj index d75eb79fbccc..2b68f52a6e71 100644 --- a/test/metabase/sync/sync_metadata/fields_test.clj +++ b/test/metabase/sync/sync_metadata/fields_test.clj @@ -152,15 +152,11 @@ (sync/sync-database! db) (let [field (t2/select-one [:model/Field :id] :name "string_tbc_int_col")] (mt/user-http-request :crowberto :put 200 (format "field/%d" (:id field)) {:coercion_strategy :Coercion/String->Integer}) - (sync/sync-database! db) - (is (=? {:effective_type :type/Integer :coercion_strategy :Coercion/String->Integer} (t2/select-one :model/Field :name "string_tbc_int_col"))) - (jdbc/execute! db-spec ["ALTER TABLE \"base_type_change_test\" ALTER COLUMN \"string_tbc_int_col\" TYPE int USING \"string_tbc_int_col\"::integer;"]) (sync/sync-database! db) - (is (=? {:coercion_strategy nil} (t2/select-one :model/Field :name "string_tbc_int_col")))))))) @@ -358,7 +354,6 @@ :steps (m/find-first (comp #{"sync-fields"} first)))] (is (=? ["sync-fields" {:total-fields 2 :updated-fields 2}] field-sync-info))))) - (testing "Two tables with same lower-case name can be synced (SEM-258)" (one-off-dbs/with-blank-db (doseq [statement [;; H2 needs that 'guest' user for QP purposes. Set that up @@ -500,7 +495,6 @@ (let [details (mt/dbdef->connection-details :postgres :db {:database-name "visibility_type_json_test" :json-unfolding true}) spec (sql-jdbc.conn/connection-details->spec :postgres details)] - (doseq [statement ["CREATE TABLE IF NOT EXISTS test_table ( id INT PRIMARY KEY, @@ -518,16 +512,12 @@ field-after-first-sync (t2/select-one :model/Field :table_id table-id :name "something")] (is (= :details-only (:visibility_type field-after-first-sync)) "First sync should set visibility_type to :details-only for large JSONB")) - (let [table-id (t2/select-one-pk :model/Table :db_id (u/the-id database) :name "test_table") field-id (t2/select-one-pk :model/Field :table_id table-id :name "something")] - (mt/user-http-request :crowberto :put 200 (format "field/%d" field-id) {:visibility_type :normal}) - (let [field-after-manual-change (t2/select-one :model/Field :id field-id)] (is (= :normal (:visibility_type field-after-manual-change)) "Manual change should set visibility_type to :normal"))) - (sync/sync-database! database) (let [table-id (t2/select-one-pk :model/Table :db_id (u/the-id database) :name "test_table") field-after-second-sync (t2/select-one :model/Field :table_id table-id :name "something")] @@ -544,22 +534,17 @@ "(3, 'Colin Fowl');")]] (jdbc/execute! one-off-dbs/*conn* [statement])) (sync/sync-database! (mt/db)) - (let [tables (t2/select-pks-set :model/Table :db_id (mt/id)) birds-example-name-field (t2/select-one :model/Field :name "example_name" :table_id [:in tables]) flocks-example-bird-name-field (t2/select-one :model/Field :name "example_bird_name" :table_id [:in tables])] - (testing "should not have FK relationship" (is (nil? (:fk_target_field_id flocks-example-bird-name-field))) (is (not= :type/FK (:semantic_type flocks-example-bird-name-field)))) - (t2/update! :model/Field (u/the-id flocks-example-bird-name-field) {:semantic_type :type/FK :fk_target_field_id (u/the-id birds-example-name-field)}) - (testing "after sync, user-set FK is preserved" (sync/sync-database! (mt/db)) - (let [field-after-sync (t2/select-one :model/Field :id (u/the-id flocks-example-bird-name-field))] (is (= :type/FK (:semantic_type field-after-sync))) (is (= (u/the-id birds-example-name-field) (:fk_target_field_id field-after-sync))))))))) diff --git a/test/metabase/sync/sync_metadata/indexes_test.clj b/test/metabase/sync/sync_metadata/indexes_test.clj index 68863526f7d0..dc666a84b837 100644 --- a/test/metabase/sync/sync_metadata/indexes_test.clj +++ b/test/metabase/sync/sync_metadata/indexes_test.clj @@ -39,7 +39,7 @@ (is (true? (t2/select-one-fn :database_indexed :model/Field (mt/id :table :first)))) (is (not= true (t2/select-one-fn :database_indexed :model/Field (mt/id :table :second)))) (finally - ;; clean the db so this test is repeatable + ;; clean the db so this test is repeatable (t2/delete! :model/Database (mt/id)) (u/ignore-exceptions (tx/destroy-db! driver/*driver* ds)))))))) diff --git a/test/metabase/sync/sync_metadata/tables_test.clj b/test/metabase/sync/sync_metadata/tables_test.clj index faef2d2bd28f..620aec88aa03 100644 --- a/test/metabase/sync/sync_metadata/tables_test.clj +++ b/test/metabase/sync/sync_metadata/tables_test.clj @@ -196,17 +196,14 @@ :db_id (u/the-id db) :active true}] (#'sync-tables/archive-tables! db) - (testing "Old deactivated table is archived with suffix" (let [archived-table (t2/select-one :model/Table (:id table-1))] (is (some? (:archived_at archived-table))) (is (str/starts-with? (:name archived-table) "old_table__mbarchiv__")))) - (testing "Recently deactivated table is not archived" (let [recent-table (t2/select-one :model/Table (:id table-2))] (is (nil? (:archived_at recent-table))) (is (= "recent_table" (:name recent-table))))) - (testing "Active table is not affected" (let [active-table (t2/select-one :model/Table (:id table-3))] (is (nil? (:archived_at active-table))) @@ -227,12 +224,10 @@ :transform_target false :deactivated_at (t/minus (t/offset-date-time) (t/days 30))}] (#'sync-tables/archive-tables! db) - (testing "Transform target table is not archived or renamed" (let [table (t2/select-one :model/Table (:id provisional))] (is (nil? (:archived_at table))) (is (= "transform_output" (:name table))))) - (testing "Normal table is archived as usual" (let [table (t2/select-one :model/Table (:id normal))] (is (some? (:archived_at table))) @@ -249,7 +244,6 @@ (let [original-name (:name table) original-archived-at (:archived_at table)] (#'sync-tables/archive-tables! db) - (let [updated-table (t2/select-one :model/Table (:id table))] (is (= original-name (:name updated-table))) (is (= original-archived-at (:archived_at updated-table)))))))) @@ -262,16 +256,13 @@ :active true}] (testing "Initially active table has no deactivated_at" (is (nil? (:deactivated_at (t2/select-one :model/Table (:id table)))))) - (testing "Setting active to false sets deactivated_at" (t2/update! :model/Table (:id table) {:active false}) (let [updated-table (t2/select-one :model/Table (:id table))] (is (some? (:deactivated_at updated-table))) (is (false? (:active updated-table))))) - (testing "Reactivating table clears deactivated_at and archived_at" (t2/update! :model/Table (:id table) {:archived_at (t/offset-date-time)}) - (t2/update! :model/Table (:id table) {:active true}) (let [reactivated-table (t2/select-one :model/Table (:id table))] (is (nil? (:deactivated_at reactivated-table))) @@ -286,16 +277,13 @@ :active false :deactivated_at (t/minus (t/offset-date-time) (t/days 20))}] (#'sync-tables/archive-tables! db) - (testing "the original table was archived and renamed" (let [archived-table (t2/select-one :model/Table (:id original-table))] (is (some? (:archived_at archived-table))) (is (str/starts-with? (:name archived-table) "sensitive_table__mbarchiv__")))) - (mt/with-temp [:model/Table new-table {:name "sensitive_table" :db_id (u/the-id db) :active true}] - (testing "the new table should be treated as completely separate" (is (not= (:id original-table) (:id new-table))) (is (= "sensitive_table" (:name new-table))) @@ -333,15 +321,12 @@ :model/Database normal-db {:is_sample false}] (let [sample-table-metadata {:name "sample_table"} normal-table-metadata {:name "normal_table"}] - (testing "creating a table in a sample database" (let [created-table (sync-tables/create-table! sample-db sample-table-metadata)] (is (= :ingested (:data_authority created-table))))) - (testing "creating a table in a normal database" (let [created-table (sync-tables/create-table! normal-db normal-table-metadata)] (is (= :unconfigured (:data_authority created-table))))) - (testing "reactivating a table in a sample database" (mt/with-temp [:model/Table existing-table {:db_id (:id sample-db) :name "existing_sample_table" diff --git a/test/metabase/sync/sync_test.clj b/test/metabase/sync/sync_test.clj index 3f21902771e1..8d8c1b547365 100644 --- a/test/metabase/sync/sync_test.clj +++ b/test/metabase/sync/sync_test.clj @@ -293,26 +293,22 @@ (is (=? {:f {:name "title" :has_field_values :auto-list} :fv nil} field-and-values))) - (testing "After querying field values they are stored" (get-or-create-vals ["a" "b" "c"]) (is (=? {:f {:name "title" :has_field_values :auto-list} :fv {:values ["a" "b" "c"] :has_more_values false}} (query-field-and-values)))) - (testing "After clearing and querying use long field values" (field-values/clear-field-values-for-field! field) (get-or-create-vals ["a" "b" "c" (apply str (map str (range 100000)))]) (is (=? {:f {:name "title" :has_field_values :auto-list} :fv {:values ["a" "b" "c"] :has_more_values true}} (query-field-and-values)))) - (testing "Querying again will use cache" (get-or-create-vals ["x" "y" "z"]) (is (=? {:f {:name "title" :has_field_values :auto-list} :fv {:values ["a" "b" "c"] :has_more_values true}} (query-field-and-values)))) - (testing "New values come in after sync" (binding [*execute-response* (fn [_query respond] (respond {:cols [{:name "field"}]} (partition-all 1 ["d" "e" "f"])))] @@ -320,7 +316,6 @@ (is (=? {:f {:name "title" :has_field_values :auto-list} :fv {:values ["d" "e" "f"] :has_more_values false}} (query-field-and-values)))) - (testing "After setting to search it should stay search and sync removes field-values" (t2/update! :model/Field (:id field) {:has_field_values "search"}) (sync/sync-database! db) diff --git a/test/metabase/sync/task/sync_databases_test.clj b/test/metabase/sync/task/sync_databases_test.clj index 203bd6027873..92890979c2e3 100644 --- a/test/metabase/sync/task/sync_databases_test.clj +++ b/test/metabase/sync/task/sync_databases_test.clj @@ -181,11 +181,9 @@ (let [db-id (:id database)] (is (= [sync-job fv-job] (current-tasks-for-db database))) - (t2/delete! :model/Database :id db-id) (let [ctx (MockJobExecutionContext. {"db-id" db-id})] (sync-fn ctx)) - (is (= [(update sync-job :triggers empty) (update fv-job :triggers empty)] (current-tasks-for-db database)))))))))) @@ -224,7 +222,6 @@ {:engine :postgres :metadata_sync_schedule "* * * * * ? *" :cache_field_values_schedule (cron-schedule-for-next-year)})))) - (testing "Make sure that a database that *isn't* marked full sync won't get analyzed" (is (= {:ran-sync? true, :ran-analyze? false, :ran-update-field-values? false} (check-if-sync-processes-ran-for-db @@ -233,7 +230,6 @@ :is_full_sync false :metadata_sync_schedule "* * * * * ? *" :cache_field_values_schedule (cron-schedule-for-next-year)})))) - (testing "Make sure the update field values task calls `update-field-values!`" (is (= {:ran-sync? false, :ran-analyze? false, :ran-update-field-values? true} (check-if-sync-processes-ran-for-db @@ -242,7 +238,6 @@ :is_full_sync true :metadata_sync_schedule (cron-schedule-for-next-year) :cache_field_values_schedule "* * * * * ? *"})))) - (testing "...but if DB is not \"full sync\" it should not get updated FieldValues" (is (= {:ran-sync? false, :ran-analyze? false, :ran-update-field-values? false} (check-if-sync-processes-ran-for-db diff --git a/test/metabase/sync/util_test.clj b/test/metabase/sync/util_test.clj index affb6139da6e..d8db3ad7df79 100644 --- a/test/metabase/sync/util_test.clj +++ b/test/metabase/sync/util_test.clj @@ -272,7 +272,6 @@ (sync-util/create-sync-step "should-continue" (fn [_] {}))]))] - ;; make sure we've ran two steps. the first one will have thrown an exception, ;; but it wasn't an exception that can cause an abort. (is (= 2 (count (:steps actual)))) @@ -291,13 +290,11 @@ db (t2/select-one :model/Database :id (mt/id))] (sync/sync-database! db) (is (= "complete" (t2/select-one-fn :initial_sync_status :model/Database :id (:id db)))))) - (testing "If `initial-sync-status` on a DB is `complete`, it remains `complete` when sync is run again" (let [_ (t2/update! :model/Database (mt/id) {:initial_sync_status "complete"}) db (t2/select-one :model/Database :id (mt/id))] (sync/sync-database! db) (is (= "complete" (t2/select-one-fn :initial_sync_status :model/Database :id (:id db)))))) - (testing "If `initial-sync-status` on a table is `incomplete`, it is marked as `complete` after the sync-fks step has finished" (let [table-id (t2/select-one-fn :id :model/Table :db_id (mt/id) :active true) @@ -305,7 +302,6 @@ _table (t2/select-one :model/Table :id table-id)] (sync/sync-database! (mt/db)) (is (= "complete" (t2/select-one-fn :initial_sync_status :model/Table :id table-id))))) - (testing "Database and table syncs are marked as complete even if the initial scan is :schema only" (let [_ (t2/update! :model/Database (mt/id) {:initial_sync_status "incomplete"}) db (t2/select-one :model/Database :id (mt/id)) @@ -315,7 +311,6 @@ (sync/sync-database! db {:scan :schema}) (is (= "complete" (t2/select-one-fn :initial_sync_status :model/Database :id (:id db)))) (is (= "complete" (t2/select-one-fn :initial_sync_status :model/Table :id table-id))))) - (testing "If a non-recoverable error occurs during sync, `initial-sync-status` on the database is set to `aborted`" (let [_ (t2/update! :model/Database (mt/id) {:initial_sync_status "incomplete"}) db (t2/select-one :model/Database :id (mt/id))] @@ -325,7 +320,6 @@ (fn [_] (throw (java.net.ConnectException.))))])] (sync/sync-database! db) (is (= "aborted" (t2/select-one-fn :initial_sync_status :model/Database :id (:id db))))))) - (testing "If `initial-sync-status` is `aborted` for a database, it is set to `complete` the next time sync finishes without error" (let [_ (t2/update! :model/Database (mt/id) {:initial_sync_status "complete"}) diff --git a/test/metabase/task/job_factory_test.clj b/test/metabase/task/job_factory_test.clj index 0e6e838c1909..875089b57fc6 100644 --- a/test/metabase/task/job_factory_test.clj +++ b/test/metabase/task/job_factory_test.clj @@ -40,13 +40,11 @@ (let [listener (sut/create-listener) noop-job (#'sut/->NoOpJob) ; Access private record constructor other-job (reify Job (execute [_ _]))] - (testing "should return true (veto) for NoOpJob" (let [context (reify JobExecutionContext (getJobInstance [_] noop-job))] (is (true? (.vetoJobExecution listener nil context)) "Listener should veto NoOpJob"))) - (testing "should return false (don't veto) for other Job types" (let [context (reify JobExecutionContext (getJobInstance [_] other-job))] diff --git a/test/metabase/task_history/api_test.clj b/test/metabase/task_history/api_test.clj index 1dd0bbd5f9bd..614afbbea8ab 100644 --- a/test/metabase/task_history/api_test.clj +++ b/test/metabase/task_history/api_test.clj @@ -69,7 +69,6 @@ (testing "Should default when only including a limit" (is (= (mt/user-http-request :crowberto :get 200 "task/" :limit 100 :offset 0) (mt/user-http-request :crowberto :get 200 "task/" :limit 100)))) - (testing "Should default when only including an offset" (is (= (mt/user-http-request :crowberto :get 200 "task/" :limit 50 :offset 100) (mt/user-http-request :crowberto :get 200 "task/" :offset 100))))) @@ -118,7 +117,6 @@ (testing "Regular user can't get task info" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 "task/info")))) - (testing "Superusers could get task info" (is (malli= [:map [:scheduler :any] @@ -293,7 +291,7 @@ :started_at (t/minus now (t/hours 1)) :ended_at (t/plus (t/minus now (t/hours 1)) (t/seconds 30))} - ;; task b + ;; task b :model/TaskHistory _ {:status :failed @@ -306,7 +304,7 @@ :task "b" :started_at (t/zoned-date-time)} - ;; task c + ;; task c :model/TaskHistory _ {:status :started @@ -370,7 +368,7 @@ :started_at (t/minus now (t/hours 1)) :ended_at (t/plus (t/minus now (t/hours 1)) (t/seconds 30))} - ;; task b + ;; task b :model/TaskHistory _ {:status :failed @@ -578,11 +576,9 @@ :started_at (t/zoned-date-time)}] (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "task/runs/%d" (:id run))))))) - (testing "404 for non-existent run" (is (= "Not found." (mt/user-http-request :crowberto :get 404 (format "task/runs/%d" Integer/MAX_VALUE))))) - (testing "superuser can get single run with tasks" (t2/delete! :model/TaskRun) (t2/delete! :model/TaskHistory) diff --git a/test/metabase/task_history/models/task_history_test.clj b/test/metabase/task_history/models/task_history_test.clj index bf13e8274890..bdbf64038d23 100644 --- a/test/metabase/task_history/models/task_history_test.clj +++ b/test/metabase/task_history/models/task_history_test.clj @@ -122,7 +122,6 @@ :task_details {:id 1 :result 42}} (t2/select-one [:model/TaskHistory :status :task_details] :task task-name))))) - (testing "on-fail-info" (let [task-name (mt/random-name)] (u/ignore-exceptions @@ -164,7 +163,6 @@ {:level "warn", :msg "warning message", :timestamp "1970-01-01T00:00:01Z", :fqns string?} {:level "error", :msg "error message", :timestamp "1970-01-01T00:00:01Z", :fqns string?}] logs))))) - (testing "logs are captured on failure" (let [task-name (mt/random-name)] (u/ignore-exceptions @@ -174,7 +172,6 @@ (let [{:keys [logs status]} (t2/select-one :model/TaskHistory :task task-name)] (is (= :failed status)) (is (=? [{:level "info", :msg "before exception", :timestamp string?, :fqns string?}] logs))))) - (testing "exception details are captured in logs" (let [task-name (mt/random-name)] (u/ignore-exceptions @@ -188,7 +185,6 @@ :fqns string? :exception (mt/malli=? [:sequential string?])}] logs))))) - (testing "debug/trace are elided" (let [task-name (mt/random-name)] (task-history/with-task-history {:task task-name} @@ -197,7 +193,6 @@ (let [{:keys [logs status]} (t2/select-one :model/TaskHistory :task task-name)] (is (= :success status)) (is (=? [{:level "error"} {:level "fatal"}] logs))))) - (testing "task with no logs" (let [task-name (mt/random-name)] (task-history/with-task-history {:task task-name}) diff --git a/test/metabase/task_history/models/task_run_test.clj b/test/metabase/task_history/models/task_run_test.clj index 7f1758ac0303..f7732d8e2327 100644 --- a/test/metabase/task_history/models/task_run_test.clj +++ b/test/metabase/task_history/models/task_run_test.clj @@ -128,7 +128,6 @@ (task-history/with-task-history {:task "t2"} :ok)) (task-history/complete-task-run! run-id) (is (= :success (:status (t2/select-one :model/TaskRun :id run-id)))))) - (testing "complete-task-run! derives :failed when any child failed" (let [run-id (task-run/create-task-run! {:run_type :sync :entity_type :database @@ -207,7 +206,6 @@ :done)) (let [th (t2/select-one :model/TaskHistory :task task-name)] (is (some? (:run_id th)) "run_id is set")))) - (testing "task history created outside with-task-run has nil run_id" (let [task-name (mt/random-name)] (task-history/with-task-history {:task task-name} @@ -255,7 +253,7 @@ inner-step (mt/random-name) outer-steps [(sync-util/create-sync-step outer-step (fn [_] - ;; Nested sync operation + ;; Nested sync operation (sync-util/sync-operation :sync mock-db "Inner sync" (sync-util/run-sync-operation "inner" mock-db diff --git a/test/metabase/task_history/task/task_run_heartbeat_test.clj b/test/metabase/task_history/task/task_run_heartbeat_test.clj index c0c1ae1e6257..29426de03bde 100644 --- a/test/metabase/task_history/task/task_run_heartbeat_test.clj +++ b/test/metabase/task_history/task/task_run_heartbeat_test.clj @@ -238,7 +238,6 @@ (heartbeat/send-heartbeat!) (let [orphaned-run-ids (heartbeat/mark-orphaned-runs!)] (heartbeat/mark-orphaned-tasks! orphaned-run-ids)) - ;; Live run should have updated heartbeat, dead run should be orphaned (let [live-run (t2/select-one :model/TaskRun :id live-run-id) dead-run (t2/select-one :model/TaskRun :id dead-run-id)] @@ -246,7 +245,6 @@ (is (t/after? (:updated_at live-run) old-time) "live run got heartbeat") (is (= :abandoned (:status dead-run)) "dead run marked abandoned") (is (some? (:ended_at dead-run)) "dead run has ended_at")) - ;; Live task should be unchanged, dead task should be marked unknown (let [live-task (t2/select-one :model/TaskHistory :id live-task-id) dead-task (t2/select-one :model/TaskHistory :id dead-task-id)] diff --git a/test/metabase/task_test.clj b/test/metabase/task_test.clj index d5c99e349425..a53fd6823e2b 100644 --- a/test/metabase/task_test.clj +++ b/test/metabase/task_test.clj @@ -148,17 +148,16 @@ (task/schedule-task! (job) (trigger-1)) (testing "make sure the job is in the database before we start the scheduler" (is (t2/exists? (capitalize-if-mysql :qrtz_job_details) (capitalize-if-mysql :job_name) "metabase.task-test.job"))) - - ;; update the job class to a non-existent class + ;; update the job class to a non-existent class (t2/update! (capitalize-if-mysql :qrtz_job_details) (capitalize-if-mysql :job_name) "metabase.task-test.job" {(capitalize-if-mysql :job_class_name) "NOT_A_REAL_CLASS"}) - ;; stop the scheduler then restart so [[task/delete-jobs-with-no-class!]] is triggered + ;; stop the scheduler then restart so [[task/delete-jobs-with-no-class!]] is triggered (task/stop-scheduler!) (task/start-scheduler!) (testing "the job should be removed from the database when the scheduler starts" (is (not (t2/exists? (capitalize-if-mysql :qrtz_job_details) (capitalize-if-mysql :job_name) "metabase.task-test.job")))) (finally - ;; restore the state of scheduler before we start the test + ;; restore the state of scheduler before we start the test (if scheduler-initialized? (task/start-scheduler!) (task/stop-scheduler!)))))) diff --git a/test/metabase/test.clj b/test/metabase/test.clj index 2382e46f66d3..00e18ca8c94f 100644 --- a/test/metabase/test.clj +++ b/test/metabase/test.clj @@ -112,7 +112,6 @@ with-actions-test-data-and-actions-enabled with-empty-db with-temp-test-data] - [data $ids dataset @@ -129,17 +128,13 @@ with-db with-temp-copy-of-db with-empty-h2-app-db!] - [data.impl *db-is-temp-copy?*] - [datasets test-driver test-drivers] - [driver with-driver] - [metabase.channel.email-test email-to fake-inbox-email-fn @@ -153,48 +148,37 @@ summarize-multipart-single-email with-expected-messages with-fake-inbox] - [client build-url client real-client client-full-response client-real-response] - [i18n.tu with-mock-i18n-bundles! with-user-locale] - [initialize initialize-if-needed!] - [lib-be application-database-metadata-provider] - [metabase.util.log.capture with-log-messages-for-level] - [mdb.test-util with-app-db-timezone-id!] - [metabase.model-persistence.test-util with-persistence-enabled!] - [metabase.request.core as-admin with-current-user] - [metabase.test.util.dynamic-redefs dynamic-value original-fn with-dynamic-fn-redefs] - [premium-features.test-util assert-has-premium-feature-error with-premium-features with-additional-premium-features when-ee-evailable] - [perms.test-util with-restored-data-perms! with-restored-data-perms-for-group! @@ -205,14 +189,11 @@ with-perm-for-group! with-perm-for-group-and-table! with-data-analyst-role!] - [qp process-query userland-query] - [qp.store with-metadata-provider] - [qp.test-util boolish->bool card-with-metadata @@ -234,13 +215,10 @@ with-database-timezone-id with-report-timezone-id! with-results-timezone-id] - [sql.qp-test-util with-native-query-testing-context] - [test-runner.assert-exprs derecordize] - [test.users fetch-user test-user? @@ -253,11 +231,9 @@ with-group with-group-for-user with-test-user] - [toucan2.tools.with-temp with-temp with-temp-defaults] - [tu boolean-ids-and-timestamps call-with-map-params @@ -307,32 +283,25 @@ with-user-in-groups with-verified! works-after] - [tu.async wait-for-result with-open-channels] - [tu.log ns-log-level set-ns-log-level! with-log-level] - [tu.misc object-defaults with-clock with-single-admin-user!] - [u.random random-name random-hash random-email] - [tu.thread-local test-helpers-set-global-values!] - [test.tz with-system-timezone-id!] - [tx arbitrary-select-query count-with-template-tag-query @@ -351,10 +320,8 @@ metabase-instance native-query-with-card-template-tag sorts-nil-first?] - [tx.env set-test-drivers!] - [schema-migrations-test.impl with-temp-empty-app-db]) diff --git a/test/metabase/test/data/dataset_definitions.clj b/test/metabase/test/data/dataset_definitions.clj index a74d9372d573..7d0a9ee3ad43 100644 --- a/test/metabase/test/data/dataset_definitions.clj +++ b/test/metabase/test/data/dataset_definitions.clj @@ -160,7 +160,7 @@ (fn [tabledef] (update tabledef :field-definitions concat [(tx/map->FieldDefinition {:field-name "created_by", :base-type :type/Integer, :fk :users})])) - ;; created_by = user.id - 1, except for User 1, who was created by himself (?) + ;; created_by = user.id - 1, except for User 1, who was created by himself (?) :rows (fn [rows] (for [[idx [username last-login password-text]] (m/indexed rows)] diff --git a/test/metabase/test/data/impl.clj b/test/metabase/test/data/impl.clj index ee83c3c456a7..c081262d8ffe 100644 --- a/test/metabase/test/data/impl.clj +++ b/test/metabase/test/data/impl.clj @@ -268,9 +268,9 @@ (-> field-values (dissoc :id) (assoc :field_id (get new-field-name->id field-name)) - ;; Toucan after-select for FieldValues returns NULL human_readable_values as [] for FE-friendliness.. - ;; preserve NULL in the app DB copy so we don't end up changing things that rely on checking whether its - ;; NULL like [[metabase.parameters.chain-filter/search-cached-field-values?]] + ;; Toucan after-select for FieldValues returns NULL human_readable_values as [] for FE-friendliness.. + ;; preserve NULL in the app DB copy so we don't end up changing things that rely on checking whether its + ;; NULL like [[metabase.parameters.chain-filter/search-cached-field-values?]] (update :human_readable_values not-empty)))))) (defn- copy-db-tables! [old-db-id new-db-id] diff --git a/test/metabase/test/data/impl/get_or_create.clj b/test/metabase/test/data/impl/get_or_create.clj index a97d40cf717e..9bdc37e1bcfa 100644 --- a/test/metabase/test/data/impl/get_or_create.clj +++ b/test/metabase/test/data/impl/get_or_create.clj @@ -284,15 +284,15 @@ full-sync? (= scan :full)] (u/profile (format "%s %s Database %s (reference H2 duration: %s)" (if full-sync? "Sync" "QUICK sync") driver database-name reference-duration) - ;; only do "quick sync" for non `test-data` datasets, because it can take literally MINUTES on CI. - ;; - ;; MEGA SUPER HACK !!! I'm experimenting with this so Redshift tests stop being so flaky on CI! It seems like - ;; if we ever delete a table sometimes Redshift still thinks it's there for a bit and sync can fail because it - ;; tries to sync a Table that is gone! So enable normal resilient sync behavior for Redshift tests to fix the - ;; flakes. If this fixes things I'll try to come up with a more robust solution. -- Cam 2024-07-19. See #45874 + ;; only do "quick sync" for non `test-data` datasets, because it can take literally MINUTES on CI. + ;; + ;; MEGA SUPER HACK !!! I'm experimenting with this so Redshift tests stop being so flaky on CI! It seems like + ;; if we ever delete a table sometimes Redshift still thinks it's there for a bit and sync can fail because it + ;; tries to sync a Table that is gone! So enable normal resilient sync behavior for Redshift tests to fix the + ;; flakes. If this fixes things I'll try to come up with a more robust solution. -- Cam 2024-07-19. See #45874 (binding [sync-util/*log-exceptions-and-continue?* (= driver :redshift)] (sync/sync-database! db {:scan scan})) - ;; add extra metadata for fields + ;; add extra metadata for fields (try (add-extra-metadata! database-definition db) (catch Throwable e @@ -403,7 +403,7 @@ (do (log/info "Data has not been loaded yet. Loading...") (u/with-timeout create-database-timeout-ms - ;; ALWAYS CREATE DATABASE AND LOAD DATA AS UTC! Unless you like broken tests. + ;; ALWAYS CREATE DATABASE AND LOAD DATA AS UTC! Unless you like broken tests. (test.tz/with-system-timezone-id! "UTC" (tx/create-db! driver dbdef))))) (tx/track-dataset driver dbdef)) diff --git a/test/metabase/test/data/interface.clj b/test/metabase/test/data/interface.clj index ce18c05ec721..c543d8dfaf54 100644 --- a/test/metabase/test/data/interface.clj +++ b/test/metabase/test/data/interface.clj @@ -72,11 +72,11 @@ [:map {:closed true} [:native ms/NonBlankString]] ms/FieldType]] - ;; this was added pretty recently (in the 44 cycle) so it might not be supported everywhere. It should work for - ;; drivers using `:sql/test-extensions` and [[metabase.test.data.sql/field-definition-sql]] but you might need to add - ;; support for it elsewhere if you want to use it. It only really matters for testing things that modify test - ;; datasets e.g. [[mt/with-actions-test-data]] - ;; default is nullable + ;; this was added pretty recently (in the 44 cycle) so it might not be supported everywhere. It should work for + ;; drivers using `:sql/test-extensions` and [[metabase.test.data.sql/field-definition-sql]] but you might need to add + ;; support for it elsewhere if you want to use it. It only really matters for testing things that modify test + ;; datasets e.g. [[mt/with-actions-test-data]] + ;; default is nullable [:not-null? {:optional true} [:maybe :boolean]] [:unique? {:optional true} [:maybe :boolean]] [:pk? {:optional true} [:maybe :boolean]] @@ -1154,32 +1154,29 @@ (doseq [driver [:h2 :sqlite]] (defmethod bad-connection-details driver [_driver] - nil)) + {:db (u.random/random-name)})) -(doseq [driver [:bigquery-cloud-sdk]] - (defmethod bad-connection-details driver - [_driver] - {:project-id (u.random/random-name)})) +(defmethod bad-connection-details :bigquery-cloud-sdk + [_driver] + {:project-id (u.random/random-name)}) (doseq [driver [:redshift :snowflake :vertica :sparksql]] (defmethod bad-connection-details driver [_driver] {:db (u.random/random-name)})) -(doseq [driver [:oracle]] - (defmethod bad-connection-details driver - [_driver] - {:service-name (u.random/random-name)})) +(defmethod bad-connection-details :oracle + [_driver] + {:service-name (u.random/random-name)}) (doseq [driver [:presto-jdbc :databricks]] (defmethod bad-connection-details driver [_driver] {:catalog (u.random/random-name)})) -(doseq [driver [:athena]] - (defmethod bad-connection-details driver - [_driver] - {:access_key (u.random/random-name)})) +(defmethod bad-connection-details :athena + [_driver] + {:access_key (u.random/random-name)}) (doseq [driver [:postgres :mysql :snowflake :databricks :redshift :sqlite :vertica :athena :oracle]] (defmethod driver/database-supports? [driver :test/arrays] diff --git a/test/metabase/test/data/sql.clj b/test/metabase/test/data/sql.clj index 38edc0cf163a..c382123aa535 100644 --- a/test/metabase/test/data/sql.clj +++ b/test/metabase/test/data/sql.clj @@ -361,7 +361,6 @@ _ (when (< 1 (count pk-names)) (throw (IllegalArgumentException. "`add-fk-sql` only works with tables with a single PK field"))) pk-name (first pk-names)] - (format "ALTER TABLE %s ADD CONSTRAINT %s FOREIGN KEY (%s) REFERENCES %s (%s);" (qualify-and-quote driver database-name table-name) ;; limit FK constraint name to 30 chars since Oracle doesn't support names longer than that diff --git a/test/metabase/test/data/sql_jdbc/load_data.clj b/test/metabase/test/data/sql_jdbc/load_data.clj index 001b96bfa1ef..f5e56ef3fd02 100644 --- a/test/metabase/test/data/sql_jdbc/load_data.clj +++ b/test/metabase/test/data/sql_jdbc/load_data.clj @@ -146,9 +146,9 @@ (if chunk-size (transduce (partition-all chunk-size) - ;; we are very deliberately not passing `rf` directly here, because calling the completing arity with it - ;; breaks things since we're not supposed to be doing that inside `reduce`. We have to use `transduce` here - ;; to get the `partition-all` transducer to work correctly tho which is why we're not just using reduce + ;; we are very deliberately not passing `rf` directly here, because calling the completing arity with it + ;; breaks things since we're not supposed to be doing that inside `reduce`. We have to use `transduce` here + ;; to get the `partition-all` transducer to work correctly tho which is why we're not just using reduce (fn ([acc] acc) diff --git a/test/metabase/test/data/sql_jdbc_test.clj b/test/metabase/test/data/sql_jdbc_test.clj index 21d7d89443fb..ad62ac6873e8 100644 --- a/test/metabase/test/data/sql_jdbc_test.clj +++ b/test/metabase/test/data/sql_jdbc_test.clj @@ -16,7 +16,7 @@ (mt/normal-drivers)) (let [dbdef (tx/get-dataset-definition defs/test-data)] (testing `tx/dataset-already-loaded? - ;; force loading of test-data + ;; force loading of test-data (mt/db) (testing "should return true for test-data" (is (tx/dataset-already-loaded? driver/*driver* dbdef))) diff --git a/test/metabase/test/generate.clj b/test/metabase/test/generate.clj index c0f9ab1aea76..bc88290cbbd4 100644 --- a/test/metabase/test/generate.clj +++ b/test/metabase/test/generate.clj @@ -306,8 +306,8 @@ :insert! {:model :model/Measure} :relations {:creator_id [:core-user :id] :table_id [:table :id]}}}) - ;; :revision {} - ;; :task-history {} +;; :revision {} +;; :task-history {} ;; * inserters (defn- spec-gen @@ -374,8 +374,8 @@ (catch clojure.lang.ExceptionInfo e (if (and (pos? num-retries) (str/includes? (ex-message e) "Couldn't satisfy such-that predicate")) - ;; We can't recur from here, and I don't think it's worth using a more complex trampoline. - ;; We are not going to overflow the stack, so this should be fine. + ;; We can't recur from here, and I don't think it's worth using a more complex trampoline. + ;; We are not going to overflow the stack, so this should be fine. (spec-gen-with-retries query (dec num-retries)) (throw e))))) diff --git a/test/metabase/test/initialize/row_lock.clj b/test/metabase/test/initialize/row_lock.clj index 17a67785629f..487d27704e0f 100644 --- a/test/metabase/test/initialize/row_lock.clj +++ b/test/metabase/test/initialize/row_lock.clj @@ -11,6 +11,6 @@ [] (let [lock-name-str (str (namespace app-db.cluster-lock/card-statistics-lock) "/" (name app-db.cluster-lock/card-statistics-lock))] - ;; Create cluster lock row before running tests + ;; Create cluster lock row before running tests (when-not (t2/exists? :metabase_cluster_lock :lock_name lock-name-str) (t2/query-one {:insert-into [:metabase_cluster_lock] :columns [:lock_name] :values [[lock-name-str]]})))) diff --git a/test/metabase/test/util.clj b/test/metabase/test/util.clj index 8d63163426fb..d3389d3420c8 100644 --- a/test/metabase/test/util.clj +++ b/test/metabase/test/util.clj @@ -483,7 +483,6 @@ (testing "Setting value" (is (= "abc" (with-temp-env-var-value-test-setting))))) - (testing "override multiple env vars" (with-temp-env-var-value! [some-fake-env-var 123, "ANOTHER_FAKE_ENV_VAR" "def"] (testing "Should convert values to strings" @@ -492,7 +491,6 @@ (testing "should handle CAPITALS/SNAKE_CASE" (is (= "def" (:another-fake-env-var env/env)))))) - (testing "validation" (are [form] (thrown? clojure.lang.Compiler$CompilerException @@ -1111,31 +1109,25 @@ [:model/Card {card-id :id :as card} {:name "A Card"} :model/Dashboard {dash-id :id :as dash} {:name "A Dashboard"}] (let [count-aux-method-before (set (methodical/aux-methods t2.before-update/before-update :model/Card :before))] - (testing "with single model" (with-discard-model-updates! [:model/Card] (t2/update! :model/Card card-id {:name "New Card name"}) (testing "the changes takes affect inside the macro" (is (= "New Card name" (t2/select-one-fn :name :model/Card card-id))))) - (testing "outside macro, the changes should be reverted" (is (= card (t2/select-one :model/Card card-id))))) - (testing "with multiple models" (with-discard-model-updates! [:model/Card :model/Dashboard] (testing "the changes takes affect inside the macro" (t2/update! :model/Card card-id {:name "New Card name"}) (is (= "New Card name" (t2/select-one-fn :name :model/Card card-id))) - (t2/update! :model/Dashboard dash-id {:name "New Dashboard name"}) (is (= "New Dashboard name" (t2/select-one-fn :name :model/Dashboard dash-id))))) - (testing "outside macro, the changes should be reverted" (is (= (dissoc card :updated_at) (dissoc (t2/select-one :model/Card card-id) :updated_at))) (is (= (dissoc dash :updated_at) (dissoc (t2/select-one :model/Dashboard dash-id) :updated_at))))) - (testing "make sure that we cleaned up the aux methods after" (is (= count-aux-method-before (set (methodical/aux-methods t2.before-update/before-update :model/Card :before)))))))) @@ -1447,7 +1439,6 @@ (reset! temp-filename filename)) (testing "File should be deleted at end of macro form" (is (not (.exists (io/file @temp-filename))))))) - (testing "explicit filename" (with-temp-file [filename "parrot-list.txt"] (is (string? filename)) @@ -1457,7 +1448,6 @@ (testing "should delete existing file" (with-temp-file [filename "parrot-list.txt"] (is (not (.exists (io/file filename)))))))) - (testing "multiple bindings" (with-temp-file [filename nil, filename-2 "parrot-list.txt"] (is (string? filename)) @@ -1466,13 +1456,11 @@ (is (not (.exists (io/file filename-2)))) (is (not (str/ends-with? filename "parrot-list.txt"))) (is (str/ends-with? filename-2 "parrot-list.txt")))) - (testing "should delete existing file" (with-temp-file [filename "parrot-list.txt"] (spit filename "wow") (with-temp-file [filename "parrot-list.txt"] (is (not (.exists (io/file filename))))))) - (testing "validation" (are [form] (thrown? clojure.lang.Compiler$CompilerException @@ -1573,7 +1561,7 @@ actual) (map? expected) - ;; recursive case (ex: to turn value that might be a flatland.ordered.map into a regular Clojure map) + ;; recursive case (ex: to turn value that might be a flatland.ordered.map into a regular Clojure map) (select-keys actual (keys expected)) :else diff --git a/test/metabase/test/util_test.clj b/test/metabase/test/util_test.clj index d3d160d5a18c..835695a5c8de 100644 --- a/test/metabase/test/util_test.clj +++ b/test/metabase/test/util_test.clj @@ -22,7 +22,6 @@ (position)))) (is (= 5 (position))))) - (testing "if an Exception is thrown, original value should be restored" (u/ignore-exceptions (mt/with-temp-vals-in-db :model/Field (data/id :venues :price) {:position -1} @@ -42,7 +41,6 @@ (mt/with-temporary-setting-values [test-util-test-setting ["D" "E" "F"]] (is (= ["D" "E" "F"] (test-util-test-setting))))) - (testing "`with-temporary-setting-values` shouldn't stomp over default values" (mt/with-temporary-setting-values [test-util-test-setting ["D" "E" "F"]] (test-util-test-setting)) @@ -68,17 +66,14 @@ {:active-count (.getActiveCount executor) :pool-size (.getPoolSize executor) :task-count (.getTaskCount executor)})}))))] - (testing "The original definition" (is (= "original" (clump "o" "riginal")))) - (future (testing "A thread that minds its own business" (log/debug "Starting no-op thread, thread-id:" (thread-id)) (is (= "123" (clump 12 3))) (take-latch) (is (= "321" (clump 3 21))))) - (future (testing "A thread that redefines it in reverse" (log/debug "Starting reverse thread, thread-id:" (thread-id)) @@ -86,7 +81,6 @@ (is (= "ok" (clump "k" "o"))) (take-latch) (is (= "ko" (clump "o" "k")))))) - (future (testing "A thread that redefines it twice" (log/debug "Starting double-redefining thread, thread-id:" (thread-id)) @@ -97,7 +91,6 @@ (take-latch) (is (= "mm" (clump "m" "l")))) (is (= "bb" (clump "a" "b")))))) - (log/debug "Taking latch on main thread, thread-id:" (thread-id)) (take-latch) (testing "The original definition survives" diff --git a/test/metabase/test_runner/assert_exprs/malli_equals.cljc b/test/metabase/test_runner/assert_exprs/malli_equals.cljc index ef48c7546d0f..9f9eb726b0ff 100644 --- a/test/metabase/test_runner/assert_exprs/malli_equals.cljc +++ b/test/metabase/test_runner/assert_exprs/malli_equals.cljc @@ -27,7 +27,6 @@ (defmethod t/assert-expr 'malli= [message [_ schema & actuals]] `(malli=-report ~message ~schema ~(vec actuals))) - ;; Clojure doing macroexpansion for ClojureScript usage. (when-let [assert-expr (try (requiring-resolve 'cljs.test/assert-expr) diff --git a/test/metabase/tracing/core_test.clj b/test/metabase/tracing/core_test.clj index 755d5ff9091b..ca824accdcbd 100644 --- a/test/metabase/tracing/core_test.clj +++ b/test/metabase/tracing/core_test.clj @@ -205,7 +205,6 @@ (finally (tracing/clear-trace-id-from-mdc!) (tracing/shutdown-groups!)))) - (testing "outermost with-span still clears MDC when no parent values exist" (try (tracing/init-enabled-groups! "all" "INFO") @@ -232,7 +231,6 @@ (finally (tracing/clear-trace-id-from-mdc!) (tracing/shutdown-groups!)))) - (testing "with-span skips pyroscope for nested spans (parent MDC already set)" (try (tracing/init-enabled-groups! "all" "INFO") @@ -245,7 +243,6 @@ (finally (tracing/clear-trace-id-from-mdc!) (tracing/shutdown-groups!)))) - (testing "rapid root span cycles don't throw or leak pyroscope state" (try (tracing/init-enabled-groups! "all" "INFO") diff --git a/test/metabase/transforms/execute_test.clj b/test/metabase/transforms/execute_test.clj index 98da3f1d2a32..6ec040738bde 100644 --- a/test/metabase/transforms/execute_test.clj +++ b/test/metabase/transforms/execute_test.clj @@ -237,7 +237,6 @@ :source {:type :query :query query} :target target-table}] - (is (thrown-with-msg? clojure.lang.ExceptionInfo #"ERROR: permission denied for database transforms-test" @@ -506,21 +505,21 @@ [(format "DROP OWNED BY %s;" readonly-user)] [(format "DROP ROLE IF EXISTS %s;" readonly-user)]]))))))))) -(deftest transform-creates-write-pool-test +(deftest transform-creates-transform-pool-test (mt/test-drivers (mt/normal-driver-select {:+parent :sql-jdbc, :+features [:transforms/table]}) (when config/ee-available? (mt/with-premium-features #{:writable-connection} (mt/dataset transforms-dataset/transforms-test - (let [db-id (mt/id) - write-cache-key [db-id :write-data] - schema (t2/select-one-fn :schema :model/Table (mt/id :transforms_products)) - old-write-details (:write_data_details (mt/db))] + (let [db-id (mt/id) + transform-cache-key [db-id :transform] + schema (t2/select-one-fn :schema :model/Table (mt/id :transforms_products)) + old-write-details (:write_data_details (mt/db))] (when-not old-write-details (t2/update! :model/Database db-id {:write_data_details (:details (mt/db))})) (try (sql-jdbc.conn/invalidate-pool-for-db! (mt/db)) - (testing "write pool does not exist before transform execution" - (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key)))) + (testing "transform pool does not exist before transform execution" + (is (not (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool transform-cache-key)))) (with-transform-cleanup! [target-table {:type "table" :schema schema :name "pool_test"}] @@ -535,8 +534,8 @@ :target target-table}] (transforms.execute/execute! transform {:run-method :manual}) (transforms.tu/wait-for-table (:name target-table) 10000) - (testing "write pool is created during transform execution" - (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool write-cache-key)))))) + (testing "transform pool is created during transform execution" + (is (contains? @@#'sql-jdbc.conn/pool-cache-key->connection-pool transform-cache-key)))))) (finally (t2/update! :model/Database db-id {:write_data_details old-write-details}) (sql-jdbc.conn/invalidate-pool-for-db! (mt/db)))))))))) diff --git a/test/metabase/transforms/jobs_test.clj b/test/metabase/transforms/jobs_test.clj index 7b31a0d25d71..08f40ff92770 100644 --- a/test/metabase/transforms/jobs_test.clj +++ b/test/metabase/transforms/jobs_test.clj @@ -35,7 +35,6 @@ :model/TransformTransformTag _ {:transform_id (:id t) :tag_id (:id tag) :position 0}] (is (= #{(:id t)} (#'jobs/job-transform-ids (:id job)))))) - (testing "job has 2 tags, transform has only 1 — must still be found" (mt/with-temp [:model/TransformTag tag-a {:name "tag-a"} :model/TransformTag tag-b {:name "tag-b"} @@ -46,7 +45,6 @@ :model/TransformTransformTag _ {:transform_id (:id t) :tag_id (:id tag-a) :position 0}] (is (= #{(:id t)} (#'jobs/job-transform-ids (:id job)))))) - (testing "job has 2 tags, two transforms with different tag subsets — both found" (mt/with-temp [:model/TransformTag tag-a {:name "tag-a"} :model/TransformTag tag-b {:name "tag-b"} @@ -59,13 +57,11 @@ :model/TransformTransformTag _ {:transform_id (:id t2) :tag_id (:id tag-b) :position 0}] (is (= #{(:id t1) (:id t2)} (#'jobs/job-transform-ids (:id job)))))) - (testing "job tag with no matching transforms — empty set" (mt/with-temp [:model/TransformTag tag {:name "tag-orphan"} :model/TransformJob job {:name "job-4" :schedule "0 0 * * * ? *"} :model/TransformJobTransformTag _ {:job_id (:id job) :tag_id (:id tag) :position 0}] (is (= #{} (#'jobs/job-transform-ids (:id job)))))) - (testing "job with no tags — empty set" (mt/with-temp [:model/TransformJob job {:name "job-5" :schedule "0 0 * * * ? *"}] (is (= #{} (#'jobs/job-transform-ids (:id job))))))) @@ -167,7 +163,6 @@ (is (re-matches #".*Skip running transform 1 due to lacking premium features.*" (:message (first @logged-messages))) "Warning message should indicate transform was skipped due to missing features"))))) - (testing "Query transforms run with :transforms-basic feature" (mt/with-premium-features #{:hosting :transforms-basic} (let [query-transform {:id 3 @@ -288,7 +283,7 @@ (is (=? {:status :failed :message string?} (t2/select-one :model/TransformJobRun :id @run-id-atom))) - ;; crowberto is a superuser/admin, so they receive the notification + ;; crowberto is a superuser/admin, so they receive the notification (is (mt/received-email-subject? :crowberto #"The job .* had failures")) (is (mt/received-email-body? :crowberto #"Uncaught error"))))))))))))) @@ -493,7 +488,7 @@ (notification.seed/seed-notification!) (let [mp (mt/metadata-provider) table (t2/select-one :model/Table (mt/id :transforms_products)) - ;; generate sql for different dbs + ;; generate sql for different dbs sql (-> (lib/query mp (lib.metadata/table mp (mt/id :transforms_products))) (lib/with-fields [(lib.metadata/field mp (mt/id :transforms_products :name))]) (lib/limit 10) @@ -513,7 +508,7 @@ :model/TransformJobTransformTag _ {:job_id (:id job) :tag_id (:id tag) :position 0} - ;; independent transform + ;; independent transform :model/Transform t0 {:name "transform0" :source {:type :query :query (lib/native-query mp sql)} @@ -522,9 +517,9 @@ :model/TransformTransformTag _tag0 {:transform_id (:id t0) :tag_id (:id tag) :position 0}] - ;; NOTE: No `with-current-user` wrapper - this simulates running the transform - ;; without a user context (e.g., from a cron job or background task). - ;; previously this could produce the wrong error message from the QP routing middleware. + ;; NOTE: No `with-current-user` wrapper - this simulates running the transform + ;; without a user context (e.g., from a cron job or background task). + ;; previously this could produce the wrong error message from the QP routing middleware. (try (jobs/run-job! (:id job) {:run-method :cron}) (catch Exception _)) @@ -534,11 +529,11 @@ (deftest get-plan-ignores-unrelated-routing-enabled-transforms-test (when config/ee-available? (testing "get-plan must not scan unrelated transforms on routing-enabled databases" - ;; Regression: a transform on a routing-enabled database is unrunnable (by design), but historically - ;; `get-plan` would fetch *every* transform in the system and call `table-dependencies` on each to - ;; build a global dependency graph. The routing-enabled transform would throw during that scan, - ;; taking down the whole scheduler and sending a misleading failure email naming the zombie - ;; transform — even when no job was asking to run it. + ;; Regression: a transform on a routing-enabled database is unrunnable (by design), but historically + ;; `get-plan` would fetch *every* transform in the system and call `table-dependencies` on each to + ;; build a global dependency graph. The routing-enabled transform would throw during that scan, + ;; taking down the whole scheduler and sending a misleading failure email naming the zombie + ;; transform — even when no job was asking to run it. (mt/with-premium-features #{:database-routing :transforms-basic} (let [mp (mt/metadata-provider)] (mt/with-temp [:model/Database _destination {:engine :h2 @@ -546,7 +541,7 @@ :details {:destination_database true}} :model/DatabaseRouter _ {:database_id (mt/id) :user_attribute "db_name"} - ;; Zombie transform on a routing-enabled database, NOT tagged to any job. + ;; Zombie transform on a routing-enabled database, NOT tagged to any job. :model/Transform _zombie {:name "zombie-transform" :source {:type :query :query (lib/native-query mp "SELECT 1")} diff --git a/test/metabase/transforms/models/transform_job_test.clj b/test/metabase/transforms/models/transform_job_test.clj index 4de1d3e595db..5d7789708390 100644 --- a/test/metabase/transforms/models/transform_job_test.clj +++ b/test/metabase/transforms/models/transform_job_test.clj @@ -64,7 +64,6 @@ (str (:description (t2/select-one :model/TransformJob (:id job)))))) (is (= (str (:name translations)) (str (:name (t2/select-one :model/TransformJob (:id job))))))))) - (testing "Setting schedule translates description and name" (doseq [[type translations] values] (mt/with-temp [:model/TransformJob job diff --git a/test/metabase/transforms/models/transform_test.clj b/test/metabase/transforms/models/transform_test.clj index 372d823abee3..c8f93779faaf 100644 --- a/test/metabase/transforms/models/transform_test.clj +++ b/test/metabase/transforms/models/transform_test.clj @@ -15,7 +15,6 @@ :type "native" :native {:query "SELECT 1"}}}}] (is (= (mt/id) (:source_database_id transform))))) - (testing "updating a transform correctly sets the source-database-id column" (mt/with-temp [:model/Transform transform {:name "Test Transform" diff --git a/test/metabase/transforms/models_test.clj b/test/metabase/transforms/models_test.clj index 816aafd5ab34..9b4020d099c8 100644 --- a/test/metabase/transforms/models_test.clj +++ b/test/metabase/transforms/models_test.clj @@ -28,7 +28,6 @@ (is (contains? tag-ids (:id tag1)) "Should include first tag") (is (contains? tag-ids (:id tag2)) "Should include second tag") (is (= 2 (count tag-ids)) "Should have exactly 2 tags"))) - (testing "Can retrieve jobs for tag" (let [job-ids (t2/select-fn-set :job_id :model/TransformJobTransformTag :tag_id (:id tag1))] (is (contains? job-ids (:id job)) "Should include the job")))))) @@ -40,12 +39,10 @@ :model/TransformJobTransformTag _ {:job_id (:id job) :tag_id (:id tag) :position 0}] ;; Delete the tag (t2/delete! :model/TransformTag :id (:id tag)) - ;; Verify cascade deletion (testing "Tag associations are deleted" (is (not (t2/exists? :model/TransformJobTransformTag :tag_id (:id tag))) "Job-tag association should be deleted")) - ;; Verify job still exists (is (t2/exists? :model/TransformJob :id (:id job)) "Job should still exist after tag deletion")))) @@ -57,12 +54,10 @@ :model/TransformJobTransformTag _ {:job_id (:id job) :tag_id (:id tag) :position 0}] ;; Delete the job (not the tag) (t2/delete! :model/TransformJob :id (:id job)) - ;; Verify cascade deletion (testing "Tag associations are deleted" (is (not (t2/exists? :model/TransformJobTransformTag :job_id (:id job))) "Job-tag association should be deleted")) - ;; Verify tag still exists (is (t2/exists? :model/TransformTag :id (:id tag)) "Tag should still exist after job deletion")))) @@ -75,7 +70,6 @@ "Should return true for existing tag name") (is (not (transform-tag/tag-name-exists? (str "nonexistent-" (u/generate-nano-id)))) "Should return false for non-existing tag name")) - (testing "tag-name-exists-excluding?" (is (not (transform-tag/tag-name-exists-excluding? (:name tag) (:id tag))) "Should return false when checking same tag's name") @@ -91,14 +85,12 @@ :model/TransformJobTransformTag _ {:job_id (:id job1) :tag_id (:id tag1) :position 0} :model/TransformJobTransformTag _ {:job_id (:id job1) :tag_id (:id tag2) :position 1} :model/TransformJobTransformTag _ {:job_id (:id job2) :tag_id (:id tag2) :position 0}] - (testing "Hydration adds tag_ids to jobs in position order" (let [[hjob1 hjob2] (t2/hydrate [job1 job2] :tag_ids)] (is (= [(:id tag1) (:id tag2)] (:tag_ids hjob1)) "Job1 should have both tags in position order") (is (= [(:id tag2)] (:tag_ids hjob2)) "Job2 should have only tag2"))) - (testing "Jobs with no tags have empty tag_ids" (mt/with-temp [:model/TransformJob job3 {}] (let [[hydrated-job] (t2/hydrate [job3] :tag_ids)] @@ -114,28 +106,23 @@ :model/TransformTag tag3 {:name "tag3"} :model/TransformTag tag4 {:name "tag4"}] (let [transform-id (:id transform)] - (testing "Transform created without creator_id defaults to internal user" (is (= config/internal-mb-user-id (:creator_id transform)))) - (testing "Initial tag order is preserved" (transform.model/update-transform-tags! transform-id [(:id tag2) (:id tag1) (:id tag3)]) (let [hydrated (t2/hydrate (t2/select-one :model/Transform :id transform-id) :transform_tag_ids)] (is (= [(:id tag2) (:id tag1) (:id tag3)] (:tag_ids hydrated)) "Tags should be in the order specified"))) - (testing "Reordering tags preserves new order" (transform.model/update-transform-tags! transform-id [(:id tag3) (:id tag1) (:id tag2)]) (let [hydrated (t2/hydrate (t2/select-one :model/Transform :id transform-id) :transform_tag_ids)] (is (= [(:id tag3) (:id tag1) (:id tag2)] (:tag_ids hydrated)) "Tags should be reordered correctly"))) - (testing "Adding and removing tags preserves order" (transform.model/update-transform-tags! transform-id [(:id tag1) (:id tag4)]) (let [hydrated (t2/hydrate (t2/select-one :model/Transform :id transform-id) :transform_tag_ids)] (is (= [(:id tag1) (:id tag4)] (:tag_ids hydrated)) "Should have only the specified tags in order"))) - (testing "Duplicate tags are deduplicated while preserving order" (transform.model/update-transform-tags! transform-id [(:id tag2) (:id tag3) (:id tag2) (:id tag1)]) (let [hydrated (t2/hydrate (t2/select-one :model/Transform :id transform-id) :transform_tag_ids)] @@ -150,13 +137,11 @@ :model/TransformTag tag2 {:name "tag2"} :model/TransformTag tag3 {:name "tag3"}] (let [job-id (:id job)] - (testing "Initial tag order is preserved" (transform-job/update-job-tags! job-id [(:id tag2) (:id tag1)]) (let [hydrated (t2/hydrate (t2/select-one :model/TransformJob :id job-id) :tag_ids)] (is (= [(:id tag2) (:id tag1)] (:tag_ids hydrated)) "Tags should be in the order specified"))) - (testing "Reordering and adding tags preserves order" (transform-job/update-job-tags! job-id [(:id tag1) (:id tag3) (:id tag2)]) (let [hydrated (t2/hydrate (t2/select-one :model/TransformJob :id job-id) :tag_ids)] @@ -197,21 +182,18 @@ :schema nil :name "test_table_null_schema" :db_id db-id}}] - (testing "Hydrates table with non-NULL schema" (let [hydrated (t2/hydrate transform1 :table-with-db-and-fields)] (is (some? (:table hydrated)) "Transform should have hydrated table") (is (= table-with-schema-id (-> hydrated :table :id)) "Should hydrate correct table with schema"))) - (testing "Hydrates table with NULL schema" (let [hydrated (t2/hydrate transform2 :table-with-db-and-fields)] (is (some? (:table hydrated)) "Transform with NULL schema should have hydrated table") (is (= table-null-schema-id (-> hydrated :table :id)) "Should hydrate correct table with NULL schema"))) - (testing "Hydrates multiple transforms with mixed schemas in single batch" (let [hydrated (t2/hydrate [transform1 transform2] :table-with-db-and-fields)] (is (= 2 (count hydrated)) @@ -221,7 +203,6 @@ (is (= #{table-with-schema-id table-null-schema-id} (set (map (comp :id :table) hydrated))) "Should hydrate both tables correctly"))) - (testing "Does not hydrate unrelated tables" (let [hydrated (t2/hydrate transform1 :table-with-db-and-fields)] (is (not= other-table-id (-> hydrated :table :id)) @@ -298,7 +279,6 @@ :status "succeeded" :run_method "manual"}] (t2/delete! :model/Transform :id transform-id) - (let [run (t2/select-one :model/TransformRun :id run-id)] (is (some? run)) (is (nil? (:transform_id run))) @@ -346,7 +326,6 @@ (testing "checkpoint is present before update" (let [t (t2/select-one :model/Transform transform-id)] (is (= "42" (:last_checkpoint_value t))))) - (testing "changing checkpoint-filter-field-id resets checkpoint" (t2/update! :model/Transform transform-id {:source {:type "query" @@ -358,7 +337,6 @@ :checkpoint-filter-field-id 200}}}) (let [t (t2/select-one :model/Transform transform-id)] (is (nil? (:last_checkpoint_value t))))) - (testing "updating without changing checkpoint-filter-field-id preserves checkpoint" ;; Set checkpoint again (t2/update! :model/Transform transform-id diff --git a/test/metabase/transforms/search_test.clj b/test/metabase/transforms/search_test.clj index 7e1211d38483..84a37fc2f94e 100644 --- a/test/metabase/transforms/search_test.clj +++ b/test/metabase/transforms/search_test.clj @@ -75,7 +75,6 @@ :model_created_at now :model_updated_at now}) ingested-transform)))))) - (testing "A simple MBQL transform gets properly ingested & indexed for search" (let [now (t/truncate-to (t/offset-date-time) :millis)] (mt/with-temp [:model/Transform {transform-id :id} {:name "Test MBQL transform" @@ -109,7 +108,6 @@ (is (string? vector-value)) (is (re-find #"select" vector-value)) (is (re-find #"sql" vector-value)))) - (mt/when-ee-evailable (mt/with-temp [:model/Transform _ {:target {:database (mt/id)} :source {:type "python" @@ -121,7 +119,6 @@ (is (string? vector-value)) (is (re-find #"import" vector-value)) (is (re-find #"panda" vector-value))))) - (testing "MBQL queries are not indexed in with_native_query_vector" (mt/with-temp [:model/Transform _ {:target {:database (mt/id) :table "test_mbql_table"} diff --git a/test/metabase/transforms/test_dataset.clj b/test/metabase/transforms/test_dataset.clj index 0e1194ead0b3..645986c98c01 100644 --- a/test/metabase/transforms/test_dataset.clj +++ b/test/metabase/transforms/test_dataset.clj @@ -24,7 +24,6 @@ ["Bob Johnson" "bob@example.com" #t "2023-07-15T10:00:00"] ["Carol White" "carol@example.com" #t "2023-08-20T10:00:00"] ["David Brown" "david@example.com" #t "2023-09-10T10:00:00"]]] - ["transforms_products" [{:field-name "name" :base-type :type/Text} {:field-name "category" :base-type :type/Text} @@ -46,7 +45,6 @@ ["Gizmo Ultra" "Gizmo" 199.99 #t "2024-01-14T10:00:00"] ["Widget E" "Widget" 44.99 #t "2024-01-15T10:00:00"] ["Gadget Mini" "Gadget" 79.99 #t "2024-01-16T10:00:00"]]] - ["transforms_orders" [{:field-name "product_id" :base-type :type/Integer :fk "transforms_products"} {:field-name "customer_id" :base-type :type/Integer :fk "transforms_customers"} diff --git a/test/metabase/transforms/util_test.clj b/test/metabase/transforms/util_test.clj index 2bd6f5a9e191..7d6e15eefa39 100644 --- a/test/metabase/transforms/util_test.clj +++ b/test/metabase/transforms/util_test.clj @@ -5,6 +5,9 @@ [clojure.test :refer :all] [metabase.api.common :as api] [metabase.driver :as driver] + [metabase.driver.connection :as driver.conn] + [metabase.driver.settings :as driver.settings] + [metabase.driver.sql-jdbc.execute :as sql-jdbc.execute] [metabase.driver.util :as driver.u] [metabase.lib.core :as lib] [metabase.permissions.models.data-permissions :as data-perms] @@ -14,8 +17,11 @@ [metabase.test.data.sql :as sql.tx] [metabase.transforms-base.interface :as transforms-base.i] [metabase.transforms-base.util :as transforms-base.u] + [metabase.transforms.canceling :as transforms.canceling] [metabase.transforms.execute :as transforms.execute] + [metabase.transforms.models.transform-run :as transform-run] [metabase.transforms.util :as transforms.u] + [metabase.util :as u] [toucan2.core :as t2])) (set! *warn-on-reflection* true) @@ -24,14 +30,12 @@ (testing "temp-table-name generates valid table names respecting driver limits" (mt/test-drivers (mt/normal-drivers-with-feature :transforms/table) (let [driver driver/*driver*] - (testing "Basic table name generation" (let [result (driver.u/temp-table-name driver nil) table-name (name result)] (is (keyword? result)) (is (nil? (namespace result))) (is (re-matches #"mb_transform_temp_table_[a-f0-9]{8}" table-name)))) - (testing "Table name preserves namespace when present" (let [result (driver.u/temp-table-name driver :schema/orders)] (is (= "schema" (namespace result))) @@ -91,7 +95,6 @@ (testing "Creating table with ordered columns" (transforms-base.u/create-table-from-schema! driver db-id table-schema) (is (driver/table-exists? driver (mt/db) {:schema schema-name :name (name table-name)}))) - (when (get-method driver/describe-table driver) (testing "Column order matches schema definition order (not alphabetical)" (let [table-metadata {:schema schema-name :name (name table-name)} @@ -102,7 +105,6 @@ (is (= expected-names column-names) (str "Expected column order " expected-names " but got " column-names))))) - (finally (try (driver/drop-table! driver db-id qualified-table-name) @@ -121,7 +123,6 @@ range-jan-feb {:start "2024-01-01T00:00:00Z" :end "2024-02-01T00:00:00Z"} range-start-only {:start "2024-01-01T00:00:00Z" :end nil} range-end-only {:start nil :end "2024-02-01T00:00:00Z"}] - (testing "with both start and end bounds" (are [expected timestamp] (= expected (matching-timestamp? {:start_time timestamp} field-path range-jan-feb)) @@ -161,17 +162,14 @@ (testing "returns nil for empty input" (is (nil? (transforms-base.u/batch-lookup-table-ids []))) (is (nil? (transforms-base.u/batch-lookup-table-ids nil)))) - (testing "looks up table without schema" (let [refs [{:database_id (:id db) :schema nil :table "table_one"}] result (transforms-base.u/batch-lookup-table-ids refs)] (is (= {[(:id db) nil "table_one"] (:id t1)} result)))) - (testing "looks up table with schema" (let [refs [{:database_id (:id db) :schema "my_schema" :table "table_two"}] result (transforms-base.u/batch-lookup-table-ids refs)] (is (= {[(:id db) "my_schema" "table_two"] (:id t2)} result)))) - (testing "handles mixed refs with and without schema" (let [refs [{:database_id (:id db) :schema nil :table "table_one"} {:database_id (:id db) :schema "my_schema" :table "table_two"}] @@ -179,7 +177,6 @@ (is (= {[(:id db) nil "table_one"] (:id t1) [(:id db) "my_schema" "table_two"] (:id t2)} result)))) - (testing "returns empty for non-existent table" (let [refs [{:database_id (:id db) :schema nil :table "nonexistent"}] result (transforms-base.u/batch-lookup-table-ids refs)] @@ -195,26 +192,21 @@ (is (= (:id db) (:database_id entry))) (is (= "existing_table" (:table entry))) (is (= (:id t1) (:table_id entry))))) - (testing "throws for non-existent table_id" (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Tables not found for ids: 999999" (transforms-base.u/normalize-source-tables [{:alias "t" :table_id 999999}])))) - (testing "populates table_id for existing table ref" (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "existing_table"}] result (transforms-base.u/normalize-source-tables source-tables)] (is (= (:id t1) (:table_id (first result)))))) - (testing "preserves existing table_id when table metadata present" (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "existing_table" :table_id 999}] result (transforms-base.u/normalize-source-tables source-tables)] (is (= 999 (:table_id (first result)))))) - (testing "creates transform target table for non-existent table ref" (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "nonexistent"}] result (transforms-base.u/normalize-source-tables source-tables)] (is (int? (:table_id (first result)))))) - (testing "handles entries needing different kinds of enrichment" (let [source-tables [{:alias "t1" :table_id (:id t1)} {:alias "t2" :database_id (:id db) :schema nil :table "existing_table"}] @@ -232,22 +224,18 @@ (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "table_one" :table_id (:id t1)}] result (transforms-base.u/resolve-source-tables source-tables)] (is (= (:id t1) (:table_id (first result)))))) - (testing "looks up table_id for entry without it" (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "table_one"}] result (transforms-base.u/resolve-source-tables source-tables)] (is (= (:id t1) (:table_id (first result)))))) - (testing "throws for non-existent table" (let [source-tables [{:alias "t" :database_id (:id db) :schema nil :table "nonexistent"}]] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Tables not found: nonexistent" (transforms-base.u/resolve-source-tables source-tables))))) - (testing "throws with schema in error message" (let [source-tables [{:alias "t" :database_id (:id db) :schema "my_schema" :table "nonexistent"}]] (is (thrown-with-msg? clojure.lang.ExceptionInfo #"Tables not found: my_schema\.nonexistent" (transforms-base.u/resolve-source-tables source-tables))))) - (testing "handles multiple entries" (let [source-tables [{:alias "t1" :table_id (:id t1) :database_id (:id db) :schema nil} {:alias "t2" :database_id (:id db) :schema nil :table "table_two"}] @@ -267,12 +255,10 @@ (let [transform {:source {:type :query :query {:database db-id}}}] (is (true? (transforms.u/source-tables-readable? transform))))) - (testing "returns true for python transform when user can read all source tables" (let [transform {:source {:type :python :source-tables [{:alias "t1" :table_id table-id}]}}] (is (true? (transforms.u/source-tables-readable? transform))))) - (testing "handles source tables with table_id" (let [transform {:source {:type :python :source-tables [{:alias "t1" :table_id table-id}]}}] @@ -285,7 +271,6 @@ (mt/with-temp [:model/Database {db-id :id} {:engine driver/*driver*} :model/Table {table1-id :id} {:db_id db-id :name "test_table_1"} :model/Table {table2-id :id} {:db_id db-id :name "test_table_2"}] - (testing "Query transforms - blocked database access" (let [transform {:source {:type :query :query {:database db-id}}}] @@ -296,7 +281,6 @@ (binding [api/*current-user-id* (:id user)] (is (false? (transforms.u/source-tables-readable? transform)) "User with blocked database access should not be able to read source database"))))))) - (testing "Python transforms - blocked database access" (let [transform {:source {:type :python :source-tables [{:alias "t1" :table_id table1-id}]}}] @@ -307,7 +291,6 @@ (binding [api/*current-user-id* (:id user)] (is (false? (transforms.u/source-tables-readable? transform)) "User with blocked database access should not be able to read source tables"))))))) - (testing "Python transforms - granular access but missing some tables" (let [transform {:source {:type :python :source-tables [{:alias "t1" :table_id table1-id} @@ -398,13 +381,118 @@ (let [hydrated (t2/hydrate table :transform)] (is (some? (:transform hydrated))) (is (= transform-id (-> hydrated :transform :id)))))))) - (testing "hydrating :transform returns nil when transform_id is nil" (mt/with-premium-features #{:transforms-basic} (mt/with-temp [:model/Table table {:transform_id nil}] (let [hydrated (t2/hydrate table :transform)] (is (nil? (:transform hydrated)))))))) +(deftest transform-pool-uses-its-own-leak-detector-test + (testing "Inside a `with-transform-connection` scope, the data-warehouse pool's + `unreturnedConnectionTimeout` default tracks the transform's bound `*query-timeout-ms*` — not the + ambient `MB_DB_QUERY_TIMEOUT_MINUTES`. This is what makes transforms safe without weakening the leak + detector on the default pool: the `:transform` pool is created with a different value (transform-timeout) + than the `:default` pool (db-query-timeout)." + (mt/with-temporary-setting-values [jdbc-data-warehouse-unreturned-connection-timeout-seconds nil] + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 20)] + (testing "outside `with-transform-connection`, the default is db-query-timeout in seconds" + (is (= (* 20 60) + (driver.settings/jdbc-data-warehouse-unreturned-connection-timeout-seconds)))) + (testing "inside `with-transform-connection` with the transform rebinding *query-timeout-ms*, the default + rises to transform-timeout in seconds — so the `:transform` pool created in this scope picks up + the longer leak-detector" + (driver.conn/with-transform-connection + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 240)] + (is (= (* 240 60) + (driver.settings/jdbc-data-warehouse-unreturned-connection-timeout-seconds)))))) + (testing "an explicit env-var/setting override still wins over the computed default" + (mt/with-temporary-setting-values [jdbc-data-warehouse-unreturned-connection-timeout-seconds 15] + (driver.conn/with-transform-connection + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 240)] + (is (= 15 (driver.settings/jdbc-data-warehouse-unreturned-connection-timeout-seconds))))))))))) + +(deftest transform-connection-type-is-distinct-pool-key-test + (testing "The pool cache key derived from `*connection-type*` distinguishes `:transform` from `:default`, so the + two contexts get separate c3p0 pools with separate properties. This is the core mechanism that keeps the + default pool's leak detector tight on non-transform instances." + (mt/with-temp [:model/Database db {:engine :h2}] + (is (= :default + (driver.conn/effective-connection-type db))) + (driver.conn/with-transform-connection + (is (= :transform + (driver.conn/effective-connection-type db))))))) + +;; Not ^:parallel: the kondo linter flags `set-statement-query-timeout!` (`!`-suffixed, so classified "destructive") +;; when used inside a parallel test. The call site here only mutates a local proxy Statement and is safe, but marking +;; synchronous avoids growing the whitelist. +(deftest set-statement-query-timeout!-test + (testing "the helper that populates Statement.setQueryTimeout reads *query-timeout-ms* and converts to seconds. + Proved via a mock Statement so the test is deterministic and independent of any driver's enforcement + semantics." + (let [captured-seconds (atom nil) + mock-stmt (proxy [java.sql.Statement] [] + (setQueryTimeout [secs] (reset! captured-seconds secs))) + set-timeout! @#'sql-jdbc.execute/set-statement-query-timeout!] + (testing "default dynamic scope" + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 3)] + (set-timeout! :h2 mock-stmt) + (is (= (* 3 60) @captured-seconds)))) + (testing "transform-scope rebinding lands in the Statement" + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 90)] + (set-timeout! :h2 mock-stmt) + (is (= (* 90 60) @captured-seconds)))) + (testing "a throwing driver does not propagate the exception" + (let [throwing-stmt (proxy [java.sql.Statement] [] + (setQueryTimeout [_] (throw (java.sql.SQLFeatureNotSupportedException.))))] + (is (nil? (set-timeout! :h2 throwing-stmt))))) + (testing "drivers that opt out via :jdbc/set-query-timeout=false skip the call entirely" + (reset! captured-seconds :not-called) + (with-redefs [driver/database-supports? (fn [_ feature _] (not= feature :jdbc/set-query-timeout))] + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms 3)] + (set-timeout! :sparksql mock-stmt) + (is (= :not-called @captured-seconds)))))))) + +(deftest statement-or-prepared-statement-round-trips-query-timeout-test + (testing "statement-or-prepared-statement creates a Statement whose .getQueryTimeout reflects the currently + bound *query-timeout-ms*, for every SQL-JDBC driver in CI that supports `:jdbc/set-query-timeout`. + Drivers that opt out (e.g. SparkSQL — Hive's Thrift transport breaks if setQueryTimeout is called) are + excluded; we just verify the Statement was created." + (mt/test-drivers (into #{} (filter #(isa? driver/hierarchy % :sql-jdbc)) (mt/normal-drivers)) + (sql-jdbc.execute/do-with-connection-with-options + driver/*driver* (mt/db) {:write? false} + (fn [^java.sql.Connection conn] + (doseq [minutes [3 90]] + (binding [driver.settings/*query-timeout-ms* (u/minutes->ms minutes)] + (with-open [^java.sql.Statement stmt (sql-jdbc.execute/statement-or-prepared-statement + driver/*driver* conn "SELECT 1" [] nil)] + (if (driver/database-supports? driver/*driver* :jdbc/set-query-timeout nil) + (is (= (* minutes 60) (.getQueryTimeout stmt)) + (str "driver " driver/*driver* " did not round-trip " minutes "min via setQueryTimeout")) + (is (some? stmt) + (str "driver " driver/*driver* " opted out of :jdbc/set-query-timeout; just confirming a Statement was returned"))))))))))) + +(deftest run-cancelable-transform!-propagates-timeout-to-driver-test + (testing "run-cancelable-transform! rebinds *query-timeout-ms* for the whole transform body, so any driver that + reads the dynamic var at query time (SQL JDBC via setQueryTimeout, Mongo/Druid/BigQuery directly) sees + the transform timeout instead of db-query-timeout." + (let [driver-observed-timeout-ms (atom nil)] + (with-redefs [driver/schema-exists? (constantly true) + driver/create-schema-if-needed! (constantly nil) + transforms-base.u/get-source-range-params (constantly nil) + transforms-base.u/save-run-checkpoint-range! (constantly nil) + transforms-base.u/save-watermark! (constantly nil) + transforms.canceling/chan-start-timeout-vthread! (constantly nil) + transforms.canceling/chan-start-run! (constantly nil) + transforms.canceling/chan-end-run! (constantly nil) + transform-run/succeed-started-run! (constantly nil)] + (mt/with-premium-features #{:transforms-basic} + (mt/with-temporary-setting-values [transform-timeout 90] + (transforms.u/run-cancelable-transform! + 1 {:id 1} :h2 {:db-id 1 :conn-spec nil :output-schema "x"} + (fn [_cancel-chan _range-params] + (reset! driver-observed-timeout-ms driver.settings/*query-timeout-ms*)))))) + (is (= (u/minutes->ms 90) @driver-observed-timeout-ms))))) + (deftest ^:parallel massage-sql-query-test (testing "massage-sql-query sets disable-remaps? and disable-max-results?" (let [query {:database 1, :type :query, :query {:source-table 1}} @@ -449,7 +537,6 @@ (let [table (t2/select-one :model/Table (:id @synced-table))] (is (= "PUBLIC" (:schema table)) "Table schema should be updated to the driver's default schema")))))) - (testing "activate-table-and-mark-computed! leaves nil schema when physical table has no default schema" (let [target {:type "table" :schema nil :name "test_nil_schema_no_default"} synced-table (atom nil)] diff --git a/test/metabase/transforms_base/ordering_unit_test.clj b/test/metabase/transforms_base/ordering_unit_test.clj index 10adee02d4c0..ce382faba8bc 100644 --- a/test/metabase/transforms_base/ordering_unit_test.clj +++ b/test/metabase/transforms_base/ordering_unit_test.clj @@ -30,33 +30,26 @@ (testing "empty start-ids returns empty ordering" (is (= {:dependencies {} :not-found #{} :failed #{}} (ordering/transform-ordering #{} [(tx 1 #{})])))) - (testing "single transform with no deps" (is (= {:dependencies {1 #{}} :not-found #{} :failed #{}} (ordering/transform-ordering #{1} [(tx 1 #{})])))) - (testing "direct dependency is resolved and included in the closure" (is (= {:dependencies {1 #{} 2 #{1}} :not-found #{} :failed #{}} (ordering/transform-ordering #{2} [(tx 1 #{}) (tx 2 #{1})])))) - (testing "transitive dependencies are walked outward" (is (= {:dependencies {1 #{} 2 #{1} 3 #{2}} :not-found #{} :failed #{}} (ordering/transform-ordering #{3} [(tx 1 #{}) (tx 2 #{1}) (tx 3 #{2})])))) - (testing "multiple start ids with a shared upstream" (is (= {:dependencies {1 #{} 2 #{1} 3 #{1}} :not-found #{} :failed #{}} (ordering/transform-ordering #{2 3} [(tx 1 #{}) (tx 2 #{1}) (tx 3 #{1})])))) - (testing "unrelated transforms are never visited or included" (let [{:keys [dependencies]} (ordering/transform-ordering #{2} [(tx 1 #{}) (tx 2 #{}) (tx 3 #{})])] (is (= {2 #{}} dependencies)) (is (not (contains? dependencies 1))) (is (not (contains? dependencies 3))))) - (testing "non-existent start ids are captured in :not-found, not in :dependencies" (is (= {:dependencies {} :not-found #{999} :failed #{}} (ordering/transform-ordering #{999} [(tx 1 #{})])))) - (testing "a cycle in the dep graph does not infinite-loop" (is (= {:dependencies {1 #{2} 2 #{1}} :not-found #{} :failed #{}} (ordering/transform-ordering #{1} [(tx 1 #{2}) (tx 2 #{1})]))))))) @@ -73,7 +66,6 @@ ;; treats 1 as a leaf, and the supposed downstream dep (2) is never visited. (is (= {:dependencies {1 #{}} :not-found #{} :failed #{1}} (ordering/transform-ordering #{1} [(tx 1 #{2}) (tx 2 #{})]))))) - (testing "failure on a discovered upstream: upstream is still included (the parent's deps found it), but has no further deps of its own" (with-redefs [transforms-base.i/table-dependencies (fn [transform] diff --git a/test/metabase/transforms_rest/api/transform_job_test.clj b/test/metabase/transforms_rest/api/transform_job_test.clj index b085f3cffc77..6d9b7711d0d1 100644 --- a/test/metabase/transforms_rest/api/transform_job_test.clj +++ b/test/metabase/transforms_rest/api/transform_job_test.clj @@ -30,14 +30,12 @@ (is (= "0 0 0 * * ?" (:schedule response))) (is (= "cron/builder" (:ui_display_type response))) (is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids response)))))) - (testing "Validates cron expression" (let [response (mt/user-http-request :lucky :post 400 "transform-job" {:name "Bad Cron Job" :schedule "invalid cron"})] (is (string? response)) (is (re-find #"Invalid cron expression" response)))) - (testing "Validates tag IDs exist" (let [response (mt/user-http-request :lucky :post 400 "transform-job" {:name "Job with bad tags" @@ -61,7 +59,6 @@ (is (= [(:id tag)] (:tag_ids response))) (is (true? (:active response))) (is (nil? (:last_run response))))) - (testing "Returns 404 for non-existent job" (mt/user-http-request :lucky :get 404 "transform-job/999999"))))))) @@ -85,7 +82,6 @@ (testing "Response hydrates creator" (is (every? #(map? (:creator %)) response)) (is (every? #(= lucky-id (get-in % [:creator :id])) response))))) - (testing "Returns 404 for non-existent job" (mt/user-http-request :lucky :get 404 "transform-job/999999/transforms")))))))) @@ -167,7 +163,6 @@ (is (= "New Description" (:description response))) (is (= "0 0 */2 * * ?" (:schedule response))) (is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids response)))))) - (testing "Validates cron expression" (let [response (mt/user-http-request :lucky :put 400 (str "transform-job/" (:id job)) {:schedule "invalid"})] @@ -308,7 +303,6 @@ (testing "Deletes job" (mt/user-http-request :lucky :delete 204 (str "transform-job/" (:id job))) (is (nil? (t2/select-one :model/TransformJob :id (:id job))))) - (testing "Returns 404 for non-existent job" (mt/user-http-request :lucky :delete 404 "transform-job/999999"))))))) diff --git a/test/metabase/transforms_rest/api/transform_tag_test.clj b/test/metabase/transforms_rest/api/transform_tag_test.clj index 235d6fcd09e1..bab42d6a4a60 100644 --- a/test/metabase/transforms_rest/api/transform_tag_test.clj +++ b/test/metabase/transforms_rest/api/transform_tag_test.clj @@ -26,13 +26,11 @@ ;; Clean up (finally (t2/delete! :model/TransformTag :id (:id response)))))) - (testing "Returns 400 for duplicate tag name" (mt/with-temp [:model/TransformTag tag {}] (is (string? (mt/user-http-request :lucky :post 400 "transform-tag" {:name (:name tag)})) "Should return 400 with error message for duplicate name"))) - (testing "Returns validation error for empty name" (let [response (mt/user-http-request :lucky :post "transform-tag" {:name ""})] @@ -53,13 +51,11 @@ {:name updated-name})] (is (= (:id tag) (:id response))) (is (= updated-name (:name response)))))) - (testing "Returns 404 for non-existent tag" (is (= "Not found." (mt/user-http-request :lucky :put 404 "transform-tag/999999" {:name "new-name"})))) - (testing "Returns 400 when updating to duplicate name" (mt/with-temp [:model/TransformTag existing-tag {} :model/TransformTag tag-to-update {}] @@ -77,7 +73,6 @@ (is (t2/exists? :model/TransformTag :id (:id tag))) (mt/user-http-request :lucky :delete 204 (str "transform-tag/" (:id tag))) (is (not (t2/exists? :model/TransformTag :id (:id tag)))))) - (testing "Returns 404 for non-existent tag" (is (= "Not found." (mt/user-http-request :lucky :delete 404 @@ -106,13 +101,10 @@ (testing "POST /api/transform-tag" (is (string? (mt/user-http-request :rasta :post 403 "transform-tag" {:name "test"})))) - (testing "GET /api/transform-tag" (is (string? (mt/user-http-request :rasta :get 403 "transform-tag")))) - (testing "PUT /api/transform-tag/:tag-id" (is (string? (mt/user-http-request :rasta :put 403 "transform-tag/1" {:name "test"})))) - (testing "DELETE /api/transform-tag/:tag-id" (is (string? (mt/user-http-request :rasta :delete 403 "transform-tag/1"))))))) diff --git a/test/metabase/transforms_rest/api/transform_test.clj b/test/metabase/transforms_rest/api/transform_test.clj index a5f1b8822234..2e5d9ea5e82d 100644 --- a/test/metabase/transforms_rest/api/transform_test.clj +++ b/test/metabase/transforms_rest/api/transform_test.clj @@ -775,7 +775,6 @@ (when-not table (throw (ex-info (str "Table not found in metadata: " table-name) {:table-name table-name}))) - ;; Build a query for the table (let [base-query (lib/query mp table) ;; Find the category column @@ -1300,26 +1299,22 @@ (is (= 1 (count items))) (is (= "transform" (:model (first items)))) (is (= "Test Transform" (:name (first items))))) - ;; Test 2: Transform appears when filtered by models=transform (let [items (:data (mt/user-http-request :lucky :get 200 (format "collection/%d/items" collection-id) :models "transform"))] (is (= 1 (count items))) (is (= transform-id (:id (first items))))) - ;; Test 3: Transform NOT returned when filtering for other models only (let [items (:data (mt/user-http-request :lucky :get 200 (format "collection/%d/items" collection-id) :models "card"))] (is (empty? items))) - ;; Test 4: Non-analysts users don't see transforms (perms/grant-collection-read-permissions! (perms/all-users-group) collection-id) (let [items (:data (mt/user-http-request :rasta :get 200 (format "collection/%d/items" collection-id)))] (is (empty? items))) - ;; Test 5: Admins see transforms (let [items (:data (mt/user-http-request :crowberto :get 200 (format "collection/%d/items" collection-id)))] @@ -1475,14 +1470,11 @@ (try ;; Add both tags to transform (transform.model/update-transform-tags! (:id transform) [(:id tag1) (:id tag2)]) - ;; Verify tags are associated (let [fetched (mt/user-http-request :lucky :get 200 (str "transform/" (:id transform)))] (is (= (set [(:id tag1) (:id tag2)]) (set (:tag_ids fetched))))) - ;; Delete tag1 (mt/user-http-request :lucky :delete 204 (str "transform-tag/" (:id tag1))) - ;; Verify tag1 is removed but tag2 remains (let [fetched (mt/user-http-request :lucky :get 200 (str "transform/" (:id transform)))] (is (= [(:id tag2)] (vec (:tag_ids fetched))))) @@ -1498,7 +1490,6 @@ (mt/with-temp [:model/TransformTag tag1 {:name "order-tag-1"} :model/TransformTag tag2 {:name "order-tag-2"} :model/TransformTag tag3 {:name "order-tag-3"}] - (let [schema (t2/select-one-fn :schema :model/Table :db_id (mt/id) :active true)] (testing "Creating transform with specific tag order preserves that order" (let [transform-request (-> (merge (mt/with-temp-defaults :model/Transform) @@ -1550,7 +1541,6 @@ (is (= "transform" (:model (first items)))) (is (= "Root Transform" (:name (first items)))))) (testing "Transform appears when filtered by models=trans form" - (let [items (:data (mt/user-http-request :crowberto :get 200 "collection/root/items" :namespace "transforms" diff --git a/test/metabase/types/core_test.cljc b/test/metabase/types/core_test.cljc index 565347b534bf..05d65b5a2e46 100644 --- a/test/metabase/types/core_test.cljc +++ b/test/metabase/types/core_test.cljc @@ -66,7 +66,6 @@ :Coercion/UNIXMilliSeconds->DateTime :Coercion/UNIXSeconds->DateTime}} (types/coercion-possibilities :type/Decimal))) - (testing "Should work for for subtypes of a the coercion base type(s)" (is (= {:type/Text #{::Coerce-Int-To-Str} :type/Instant #{:Coercion/UNIXNanoSeconds->DateTime @@ -75,7 +74,6 @@ :Coercion/UNIXSeconds->DateTime ::Coerce-BigInteger-To-Instant}} (types/coercion-possibilities :type/BigInteger)))) - (testing "Should *not* work for ancestor types of the coercion base type(s)" (is (= nil (types/coercion-possibilities :type/Number)))) diff --git a/test/metabase/upload/impl_test.clj b/test/metabase/upload/impl_test.clj index a7720ff30a41..8b0ee1a64632 100644 --- a/test/metabase/upload/impl_test.clj +++ b/test/metabase/upload/impl_test.clj @@ -458,7 +458,7 @@ (is (= (ddl.i/format-name driver/*driver* "chu_se_de_20240628000000") (:name table))))))))) (testing "The names should be truncated to the right size" - ;; we can assume app DBs use UTF-8 encoding (metabase#11753) + ;; we can assume app DBs use UTF-8 encoding (metabase#11753) (let [max-bytes 15] (with-redefs [; redef this because the UNIX filename limit is 255 bytes, so we can't test it in CI upload/max-bytes (constantly max-bytes)] @@ -1223,7 +1223,6 @@ "upload_seconds" pos?} :user-id (str (mt/user->id :rasta))} (last (snowplow-test/pop-event-data-and-user-id!))))) - (testing "Failures when creating a CSV Upload will publish statistics to Snowplow" (mt/with-dynamic-fn-redefs [upload/create-from-csv! (fn [_ _ _ _] (throw (Exception.)))] (try (do-with-uploaded-example-csv! {} identity) @@ -1482,7 +1481,7 @@ (t2/update! :model/Table table-id {:is_upload is-upload}) (try (update-csv! action {:table-id table-id, :file file}) (finally - ;; Drop the table in the testdb if a new one was created. + ;; Drop the table in the testdb if a new one was created. (when (and new-table (not= driver/*driver* :redshift)) ; redshift tests flake when tables are dropped (driver/drop-table! driver/*driver* (mt/id) @@ -1596,7 +1595,6 @@ :id int-type :name text-type) :rows [[1 long-text]]})] - (let [file (csv-file-with csv-rows)] (when error-message (is (= {:message error-message @@ -1605,12 +1603,10 @@ (testing "Check the data was not uploaded into the table" (is (= [[1 long-text]] (rows-for-table table))))) - (when-not error-message (testing "Check the data was uploaded into the table" ;; No exception is thrown - but there were also no rows in the table to check (update-csv! action {:file file :table-id (:id table)}))) - (io/delete-file file))))))))))) (deftest update-common-types-test @@ -1818,13 +1814,11 @@ (doseq [action (actions-to-test driver/*driver*)] (testing (action-testing-str action) (snowplow-test/with-fake-snowplow-collector - (with-upload-table! [table (create-upload-table!)] (testing "Successfully appending to CSV Uploads publishes statistics to Snowplow" (let [csv-rows ["name" "Luke Skywalker"] file (csv-file-with csv-rows (mt/random-name))] (update-csv! action {:file file, :table-id (:id table)}) - (is (=? {:data {"event" "csv_append_successful" "size_mb" 1.811981201171875E-5 "num_columns" 1 @@ -1833,9 +1827,7 @@ "upload_seconds" pos?} :user-id (str (mt/user->id :crowberto))} (last (snowplow-test/pop-event-data-and-user-id!)))) - (io/delete-file file))) - (testing "Failures when appending to CSV Uploads will publish statistics to Snowplow" (mt/with-dynamic-fn-redefs [upload/create-from-csv! (fn [_ _ _ _] (throw (Exception.)))] (let [csv-rows ["mispelled_name, unexpected_column" "Duke Cakewalker, r2dj"] @@ -1845,7 +1837,6 @@ (catch Throwable _) (finally (io/delete-file file)))) - (is (= {:data {"event" "csv_append_failed" "size_mb" 5.245208740234375E-5 "num_columns" 2 @@ -1865,9 +1856,7 @@ event-type (case action :metabase.upload/append :upload-append :metabase.upload/replace :upload-replace)] - (update-csv! action {:file file, :table-id (:id table)}) - (is (=? {:topic event-type :user_id (:id (mt/fetch-user :crowberto)) :model "Table" @@ -1881,7 +1870,6 @@ :size-mb 1.811981201171875E-5 :upload-seconds pos?}}} (last-audit-event event-type))) - (io/delete-file file)))))))) (defn- mbql [mp table] @@ -1899,7 +1887,6 @@ (lib.metadata/field mp field-id))) base-id-metadata (pk-metadata base-table) join-id-metadata (pk-metadata join-table)] - (-> (lib/query mp base-table-metadata) (lib/join (lib/join-clause join-table-metadata [(lib/= (lib/ref base-id-metadata) @@ -1919,24 +1906,19 @@ other-id (mt/id :venues) other-table (t2/select-one :model/Table other-id) mp (lib-be/application-database-metadata-provider (:db_id table))] - (mt/with-temp [:model/Card {question-id :id} {:table_id table-id, :dataset_query (mbql mp table)} :model/Card {model-id :id} {:table_id table-id, :type :model, :dataset_query (mbql mp table)} :model/Card {complex-model-id :id} {:table_id table-id, :type :model, :dataset_query (join-mbql mp table other-table)} :model/Card {archived-model-id :id} {:table_id table-id, :type :model, :archived true, :dataset_query (mbql mp table)} :model/Card {unrelated-model-id :id} {:table_id other-id, :type :model, :dataset_query (mbql mp other-table)} :model/Card {joined-model-id :id} {:table_id other-id, :type :model, :dataset_query (join-mbql mp other-table table)}] - (is (= #{question-id model-id complex-model-id} (into #{} (map :id) (t2/select :model/Card :table_id table-id :archived false)))) - (mt/with-persistence-enabled! [persist-models!] (persist-models!) - (let [cached-before (cached-model-ids) _ (update-csv! action {:file file, :table-id (:id table)}) cached-after (cached-model-ids)] - (testing "The models are cached" (let [active-model-ids #{model-id complex-model-id unrelated-model-id joined-model-id}] (is (= active-model-ids (set/intersection cached-before (conj active-model-ids archived-model-id)))))) @@ -1952,7 +1934,6 @@ (and (= "Luke Skywalker" row-name) (= 57 age))) (rows-for-model (:db_id table) model-id))))))) - (io/delete-file file))))))) (deftest update-mb-row-id-csv-and-table-test @@ -1972,7 +1953,6 @@ [["Luke Skywalker"]])) (set (rows-for-table table))))) (io/delete-file file))) - ;; TODO we can deduplicate a lot of code in this test (testing "with duplicate normalized _mb_row_id columns in the CSV file" (with-upload-table! [table (create-upload-table!)] @@ -2017,7 +1997,6 @@ :rows [["Obi-Wan Kenobi" "No one really knows me"]])] (let [csv-rows ["shame,name" "Nothing - you can't prove it,Puke Nightstalker"] file (csv-file-with csv-rows)] - (testing "The new row is inserted with the values correctly reordered" (is (= {:row-count 1} (update-csv! action {:file file, :table-id (:id table)}))) (is (= (set (updated-contents action @@ -2033,7 +2012,7 @@ (with-uploads-enabled! (testing "Append should handle new columns being added in the latest CSV" (with-upload-table! [table (create-upload-table!)] - ;; Reorder as well for good measure + ;; Reorder as well for good measure (let [csv-rows ["game,name" "Witticisms,Fluke Skytalker"] file (csv-file-with csv-rows)] (testing "The new row is inserted with the values correctly reordered" @@ -2053,7 +2032,7 @@ (with-upload-table! [table (create-upload-table!)] (is (= (header-with-auto-pk ["Name"]) (column-display-names-for-table table))) - ;; Reorder as well for good measure + ;; Reorder as well for good measure (let [csv-rows ["α,name" "omega,Everything"] file (csv-file-with csv-rows)] @@ -2075,10 +2054,10 @@ (testing "Append should handle new non-ascii columns being added in the latest CSV" (with-upload-table! [table (create-upload-table! :col->upload-type (columns-with-auto-pk {"α" ::upload-types/varchar-255}))] - ;; We can't type a literal uppercase Alpha, as our whitespace linter will complain. + ;; We can't type a literal uppercase Alpha, as our whitespace linter will complain. (is (= (header-with-auto-pk [(u/upper-case-en "α")]) (column-display-names-for-table table))) - ;; Reorder as well for good measure + ;; Reorder as well for good measure (let [csv-rows ["α,ϐ" "omega,Everything"] file (csv-file-with csv-rows)] @@ -2388,7 +2367,6 @@ :number_1 int-type :number_2 int-type)) :rows [[1, 1]])] - (let [csv-rows ["number-1, number-2" "1.0, 1" "1 , 1.0"] @@ -2460,23 +2438,18 @@ :number_1 int-type :number_2 int-type)) :rows [[1, 1]])] - (testing "The upload table and the expected application data are created\n" (is (upload-table-exists? table)) (is (seq (t2/select :model/Table :id (:id table)))) (testing "The expected metadata is synchronously sync'd" (is (seq (t2/select :model/Field :table_id (:id table)))))) - (mt/with-temp [:model/Card {card-id :id} {:table_id (:id table)}] (is (false? (:archived (t2/select-one :model/Card :id card-id)))) - (upload/delete-upload! table :archive-cards? archive-cards?) - (testing (format "We %s the related cards if archive-cards? is %s" (if archive-cards? "archive" "do not archive") archive-cards?) (is (= archive-cards? (:archived (t2/select-one :model/Card :id card-id))))) - (testing "The upload table and related application data are deleted\n" (is (not (upload-table-exists? table))) (is (= [false] (mapv :active (t2/select :model/Table :id (:id table))))) diff --git a/test/metabase/upload/types_test.clj b/test/metabase/upload/types_test.clj index d398fa864e38..dafc7cdca78b 100644 --- a/test/metabase/upload/types_test.clj +++ b/test/metabase/upload/types_test.clj @@ -275,7 +275,6 @@ (is (= [::text] (vec (ancestors h ::varchar-255)))) (is (= [::varchar-255 ::text] (vec (ancestors h ::boolean)))) (is (= [::*float-or-int* ::float ::varchar-255 ::text] (vec (ancestors h ::int))))) - (testing "Non-linear ancestors are listed in topological order, following edges in the order they were defined." (is (= [::boolean ::int @@ -291,7 +290,6 @@ (is (nil? (descendants h ::date))) (is (= [::date] (vec (descendants h ::datetime)))) (is (= [::*boolean-int*] (vec (descendants h ::boolean))))) - (testing "Non-linear descendants are listed in reverse topological order, following edges in reserve order." (is (= [::*float-or-int* ::int diff --git a/test/metabase/users/models/user_parameter_value_test.clj b/test/metabase/users/models/user_parameter_value_test.clj index 47f9e8463feb..3f48cdb28c12 100644 --- a/test/metabase/users/models/user_parameter_value_test.clj +++ b/test/metabase/users/models/user_parameter_value_test.clj @@ -27,26 +27,22 @@ "param2" "string" "param3" ["A" "B" "C"]} (retrieve-fn)))) - (testing "delete if value is nil" (store! [{:id "param1" :value "foo"} {:id "param2" :value nil}]) (is (= {"param1" "foo" "param3" ["A" "B" "C"]} (retrieve-fn)))) - (testing "last value wins" (store! [{:id "param1" :value "bar"} {:id "param1" :value "baz"}]) (is (= {"param1" "baz" "param3" ["A" "B" "C"]} (retrieve-fn)))) - (testing "update existing param and insert new param" (store! [{:id "param1", :value "new-value"} {:id "param2", :value "new-value"}]) (is (= {"param1" "new-value" "param2" "new-value" "param3" ["A" "B" "C"]} (retrieve-fn)))) - (testing "insert nil if param has default value" (store! [{:id "param4" :value nil :default "default"}]) (is (= {"param1" "new-value" diff --git a/test/metabase/users_rest/api_test.clj b/test/metabase/users_rest/api_test.clj index cff78b4330fc..229be20890d9 100644 --- a/test/metabase/users_rest/api_test.clj +++ b/test/metabase/users_rest/api_test.clj @@ -102,7 +102,6 @@ "rasta@metabase.com" "analyst-list@metabase.com"} (set (map :email result))))))) - (testing "A sandboxed data analyst only sees themselves" (mt/with-temp [:model/User {_ :id :as analyst} {:first_name "Sandboxed" :last_name "Analyst" @@ -211,7 +210,6 @@ (mt/with-temporary-setting-values [user-visibility visibility-value] (testing "`user-visibility` setting returns the default value" (is (= :all (users.settings/user-visibility)))) - (testing "return all user by default" (is (= [crowberto lucky rasta] (->> (:data (mt/user-http-request :rasta :get 200 "user/recipients")) @@ -231,14 +229,12 @@ (->> ((mt/user-http-request :rasta :get 200 "user/recipients") :data) (filter mt/test-user?) (map :email)))))) - (testing "Returns all users when admin" (mt/with-temporary-setting-values [user-visibility "none"] (is (= [crowberto lucky rasta] (->> ((mt/user-http-request :crowberto :get 200 "user/recipients") :data) (filter mt/test-user?) (map :email)))))) - (testing "Returns users in the group when user-visibility is same group" (mt/with-temporary-setting-values [user-visibility :group] (mt/with-temp @@ -251,13 +247,11 @@ (is (= [crowberto rasta] (->> (:data (mt/user-http-request :rasta :get 200 "user/recipients")) (map :email)))) - (testing "But returns self if the user is sandboxed" (with-redefs [perms-util/sandboxed-or-impersonated-user? (constantly true)] (is (= [rasta] (->> ((mt/user-http-request :rasta :get 200 "user/recipients") :data) (map :email))))))))) - (testing "Returns only self when user-visibility is none" (mt/with-temporary-setting-values [user-visibility :none] (is (= [rasta] @@ -539,7 +533,6 @@ (is (partial= {:can_create_queries true :can_create_native_queries true} (user-permissions :crowberto)))) - (testing "user with query-builder-and-native on a non-sample DB" (mt/with-temp [:model/Database {db-id :id} {:is_sample false}] (mt/with-all-users-data-perms-graph! {db-id {:view-data :unrestricted @@ -547,7 +540,6 @@ (is (partial= {:can_create_queries true :can_create_native_queries true} (user-permissions :rasta)))))) - (testing "user with only query-builder (no native) on a non-sample DB" (mt/with-temp [:model/Database {db-id :id} {:is_sample false}] (mt/with-all-users-data-perms-graph! {db-id {:view-data :unrestricted @@ -555,7 +547,6 @@ (is (partial= {:can_create_queries true :can_create_native_queries false} (user-permissions :rasta)))))) - (testing "user with no query permissions on non-sample DBs" (mt/with-temp [:model/Database {db-id :id} {:is_sample false}] (mt/with-all-users-data-perms-graph! {db-id {:view-data :unrestricted @@ -563,7 +554,6 @@ (is (partial= {:can_create_queries false :can_create_native_queries false} (user-permissions :rasta)))))) - (testing "at least one non-sample DB with native permission is enough" (mt/with-temp [:model/Database {db1-id :id} {:is_sample false} :model/Database {db2-id :id} {:is_sample false}] @@ -638,12 +628,10 @@ (let [response (mt/user-http-request :crowberto :get 200 (str "user/" (:id user)))] (testing "response includes structured_attributes" (is (contains? response :structured_attributes))) - (testing "structured_attributes has correct format for login attributes" (is (= {:role {:source "user" :frozen false :value "admin"} :department {:source "user" :frozen false :value "engineering"}} (:structured_attributes response)))) - (testing "structured_attributes is included for self-fetch" (let [self-response (mt/client {:username "structured@test.com" :password "p@ssw0rd"} :get 200 (str "user/" (:id user)))] @@ -666,7 +654,6 @@ :env {:source "jwt" :frozen false :value "production"} :department {:source "user" :frozen false :value "engineering"}} (:structured_attributes response))))))) - (testing "with only login attributes" (mt/with-temp [:model/User user {:first_name "Test" :last_name "User" @@ -678,7 +665,6 @@ (is (= {:key1 {:source "user" :frozen false :value "value1"} :key2 {:source "user" :frozen false :value "value2"}} (:structured_attributes response)))))) - (testing "with no attributes" (mt/with-temp [:model/User user {:first_name "Test" :last_name "User" @@ -688,7 +674,6 @@ (let [response (mt/user-http-request :crowberto :get 200 (str "user/" (:id user)))] (is (= {} (:structured_attributes response)))))) - (testing "with empty attribute maps" (mt/with-temp [:model/User user {:first_name "Test" :last_name "User" @@ -698,7 +683,6 @@ (let [response (mt/user-http-request :crowberto :get 200 (str "user/" (:id user)))] (is (= {} (:structured_attributes response)))))) - (testing "JWT attributes preserve original value when overriding" (mt/with-temp [:model/User user {:first_name "Test" :last_name "User" @@ -742,7 +726,6 @@ (#'api.user/combine {:user {"key1" "value1"} :jwt {"key2" "value2"}} nil)))) - (testing "User overrides user attributes" (is (= {"key" {:source :user :frozen false @@ -751,12 +734,10 @@ (#'api.user/combine {:user {"key" "user-value"} :jwt {"key" "jwt-value"}} nil)))) - (testing "system attributes are frozen" (is (= {"@system.key" {:source :system :frozen true :value "system-value"}} (#'api.user/combine {} {"@system.key" "system-value"})))) - (testing "empty inputs produce empty output" (is (= {} (#'api.user/combine {:user nil :jwt nil} nil))) @@ -818,7 +799,6 @@ (mt/user-http-request :crowberto :post 400 "user" {:first_name "whatever" :last_name "whatever"}))) - (is (=? {:errors {:email "value must be a valid email address."}} (mt/user-http-request :crowberto :post 400 "user" {:first_name "whatever" @@ -1268,7 +1248,6 @@ (mt/user-http-request :crowberto :put 200 (str "user/" user-id) {:is_data_analyst true}) (is (user-is-data-analyst? user-id)))) - (testing "Test that a superuser can unset the :is_data_analyst flag (removes from Data Analysts group)" (mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test-analyst-unset@metabase.com"}] (mt/user-http-request :crowberto :put 200 (str "user/" user-id) @@ -1277,13 +1256,11 @@ (mt/user-http-request :crowberto :put 200 (str "user/" user-id) {:is_data_analyst false}) (is (not (user-is-data-analyst? user-id))))) - (testing "Test that a normal user cannot change the :is_data_analyst flag for themselves" (is (not (user-is-data-analyst? (mt/user->id :rasta)))) (mt/user-http-request :rasta :put 200 (str "user/" (mt/user->id :rasta)) {:is_data_analyst true}) (is (not (user-is-data-analyst? (mt/user->id :rasta))))) - (testing "Test that a normal user cannot change the :is_data_analyst flag for another user" (mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test-analyst2@metabase.com"}] (is (= "You don't have permissions to do that." @@ -1304,7 +1281,6 @@ (is (contains? result-ids analyst-id))) (testing "non-analyst is excluded" (is (not (contains? result-ids non-analyst-id))))))) - (testing "Filter users by is_data_analyst=false excludes data analysts group members" (mt/with-temp [:model/User {analyst-id :id} {:first_name "Analyst2" :last_name "User" @@ -1325,12 +1301,10 @@ (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 (str "user/" (mt/user->id :trashbird)) {:email "toucan@metabase.com"})))) - (testing "We should get a 404 when trying to access a disabled account" (is (= "Not found." (mt/user-http-request :crowberto :put 404 (str "user/" (mt/user->id :trashbird)) {:email "toucan@metabase.com"})))) - (testing "Google auth users shouldn't be able to change their own password as we get that from Google" (mt/with-temp [:model/User user {:email "anemail@metabase.com" :password "def123" @@ -1340,7 +1314,6 @@ (is (= "You don't have permissions to do that." (client/client creds :put 403 (format "user/%d" (u/the-id user)) {:email "adifferentemail@metabase.com"})))))) - (testing (str "Similar to Google auth accounts, we should not allow LDAP users to change their own email address " "as we get that from the LDAP server") (mt/with-temp [:model/User user {:email "anemail@metabase.com" @@ -1362,7 +1335,6 @@ :password "def123"}] (client/client creds :put 200 (format "user/%d" (u/the-id user)) {:locale "id"})))) - (testing "LDAP users can change their locale" (mt/with-temp [:model/User user {:email "anemail@metabase.com" :password "def123" @@ -1518,7 +1490,6 @@ (testing "value in DB should be updated to new locale" (is (= (i18n/normalized-locale-string locale) (locale-from-db))))))) - (testing "admins should be able to update someone else's locale" (testing "response" (is (= "en_US" @@ -1526,7 +1497,6 @@ (testing "value in DB should be updated and normalized" (is (= "en_US" (locale-from-db))))) - (testing "normal Users should not be able to update someone else's locale" (testing "response" (is (= "You don't have permissions to do that." @@ -1534,7 +1504,6 @@ (testing "value in DB should be unchanged" (is (= "en_US" (locale-from-db))))) - (testing "attempting to set an invalid locales should result in an error" (doseq [[group locales] {"invalid input" [nil "" 100 "ab/cd" "USA!"] "3-letter codes" ["eng" "eng-USA"] @@ -1565,16 +1534,13 @@ (is (true? (t2/select-one-fn :is_active :model/User :id (:id user))) "the user should now be active"))) - (testing "error conditions" (testing "Attempting to reactivate a non-existant user should return a 404" (is (= "Not found." (mt/user-http-request :crowberto :put 404 (format "user/%s/reactivate" Integer/MAX_VALUE))))) - (testing " Attempting to reactivate an already active user should fail" (is (=? {:message "Not able to reactivate an active user"} (mt/user-http-request :crowberto :put 400 (format "user/%s/reactivate" (mt/user->id :rasta))))))) - (testing (str "test that when disabling Google auth if a user gets disabled and re-enabled they are no longer " "Google Auth (#3323)") (mt/with-temporary-setting-values [google-auth-client-id "pretend-client-id.apps.googleusercontent.com" @@ -1632,7 +1598,6 @@ (testing "Test input validations on password change" (is (=? {:errors {:password "password is too common."}} (mt/user-http-request :rasta :put 400 (format "user/%d/password" (mt/user->id :rasta)) {})))) - (testing "Make sure that if current password doesn't match we get a 400" (is (=? {:errors {:old_password "Invalid password"}} (mt/user-http-request :rasta :put 400 (format "user/%d/password" (mt/user->id :rasta)) @@ -1648,7 +1613,6 @@ :success true} (mt/client creds :put 200 (format "user/%d/password" (:id user)) {:password "abc123!!DEF" :old_password "def"})))))) - (testing "Test that we don't return a session if we are changing our someone else's password as a superuser" (mt/with-temp [:model/User user {:password "def", :is_superuser false}] (is (nil? (mt/user-http-request :crowberto :put 204 (format "user/%d/password" (:id user)) {:password "abc123!!DEF" @@ -1663,23 +1627,19 @@ (mt/with-temp [:model/User user] (is (= {:success true} (mt/user-http-request :crowberto :delete 200 (format "user/%d" (:id user)) {}))) - (testing "User should still exist, but be inactive" (is (= {:is_active false} (mt/derecordize (t2/select-one [:model/User :is_active] :id (:id user))))))) - (testing "Check that the last superuser cannot deactivate themselves" (mt/with-single-admin-user! [{id :id}] (is (= "You cannot remove the last member of the 'Admin' group!" (mt/user-http-request id :delete 400 (format "user/%d" id)))))) - (testing "Check that the last non-archived superuser cannot deactivate themselves" (mt/with-single-admin-user! [{id :id}] (mt/with-temp [:model/User _ {:is_active false :is_superuser true}] (is (= "You cannot remove the last member of the 'Admin' group!" (mt/user-http-request id :delete 400 (format "user/%d" id))))))) - (testing "Check that a non-superuser CANNOT deactivate themselves" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :delete 403 (format "user/%d" (mt/user->id :rasta)) {})))))) @@ -1718,7 +1678,6 @@ (mt/client creds :put 200 (format "user/%d/modal/%s" id endpoint))))) (testing (str endpoint "?") (is (false? (t2/select-one-fn property :model/User, :id id))))))) - (testing "shouldn't be allowed to set someone else's status" (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :put 403 diff --git a/test/metabase/util/compress_test.clj b/test/metabase/util/compress_test.clj index 48219d534eeb..90577a3b1196 100644 --- a/test/metabase/util/compress_test.clj +++ b/test/metabase/util/compress_test.clj @@ -22,19 +22,16 @@ (try (spit (io/file dir "one") (mt/random-hash)) (spit (io/file dir "two") (mt/random-hash)) - (testing "it is indeed a gzip archive" (u.compress/tgz dir archive) (let [bytes (Files/readAllBytes (.toPath archive))] ;; https://www.ietf.org/rfc/rfc1952.txt, section 2.3.1 (is (= [(unchecked-byte 0x1f) (unchecked-byte 0x8b)] (take 2 bytes))))) - (testing "uncompressing generates identical folder" (u.compress/untgz archive out) (is (= (mapv slurp (filter #(.isFile ^File %) (file-seq dir))) (mapv slurp (filter #(.isFile ^File %) (file-seq out)))))) - (finally (run! io/delete-file (reverse (file-seq dir))) (when (.exists archive) diff --git a/test/metabase/util/date_2_test.clj b/test/metabase/util/date_2_test.clj index 516ee72ec66c..cdb7292508d5 100644 --- a/test/metabase/util/date_2_test.clj +++ b/test/metabase/util/date_2_test.clj @@ -516,80 +516,61 @@ [[(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "Asia/Tokyo")) (t/zoned-date-time "2011-04-17T15:00:00Z[UTC]") "UTC"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "Asia/Tokyo")) (t/zoned-date-time "2011-04-18T00:00:00+09:00[Asia/Tokyo]") "Asia/Tokyo"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "UTC")) (t/zoned-date-time "2011-04-18T09:00:00+09:00[Asia/Tokyo]") "Asia/Tokyo"] - [(t/zoned-date-time 2011 4 18 0 0 0 0 (t/zone-id "UTC")) (t/zoned-date-time "2011-04-18T00:00:00Z[UTC]") "UTC"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 9)) (t/offset-date-time "2011-04-17T15:00:00Z") "UTC"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 9)) (t/offset-date-time "2011-04-18T00:00:00+09:00") "Asia/Tokyo"] - [(t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0)) (t/offset-date-time "2011-04-18T09:00:00+09:00") "Asia/Tokyo"] - ;; instants should return arg as-is since they're always normalized to UTC [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) (t/instant "2011-04-18T00:00:00Z") "UTC"] - [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) (t/instant "2011-04-18T00:00:00Z") "Asia/Tokyo"] - [(t/instant (t/offset-date-time 2011 4 18 0 0 0 0 (t/zone-offset 0))) (t/instant "2011-04-18T00:00:00Z") "UTC"] - [(t/local-date-time 2011 4 18 0 0 0 0) (t/offset-date-time "2011-04-18T00:00:00+09:00") "Asia/Tokyo"] - [(t/local-date-time 2011 4 18 0 0 0 0) (t/offset-date-time "2011-04-18T00:00:00Z") "UTC"] - [(t/local-date 2011 4 18) (t/offset-date-time "2011-04-18T00:00:00+09:00") "Asia/Tokyo"] - [(t/local-date 2011 4 18) (t/offset-date-time "2011-04-18T00:00:00Z") "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 9)) (t/offset-time "10:55:00Z") "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 9)) (t/offset-time "19:55:00+09:00") "Asia/Tokyo"] - [(t/offset-time 19 55 0 0 (t/zone-offset 0)) (t/offset-time "19:55:00Z") "UTC"] - [(t/offset-time 19 55 0 0 (t/zone-offset 0)) (t/offset-time "04:55:00+09:00") "Asia/Tokyo"] - [(t/local-time 19 55) (t/offset-time "19:55:00Z") "UTC"] - [(t/local-time 19 55) (t/offset-time "19:55:00+09:00") "Asia/Tokyo"]]] diff --git a/test/metabase/util/formatting/date_test.cljc b/test/metabase/util/formatting/date_test.cljc index 4b845ced4003..485890775607 100644 --- a/test/metabase/util/formatting/date_test.cljc +++ b/test/metabase/util/formatting/date_test.cljc @@ -62,11 +62,9 @@ (testing "in different years, even with :compact true" (is (= (str "Dec 29, 2019" date/range-separator "Jan 4, 2020") (week-of "2019-12-31T11:19:04"))))) - (testing "split months, shared year (M d - M d, Y" (is (= (str "Aug 28" date/range-separator "Sep 3, 2022") (week-of "2022-08-31T11:19:04")))) - (testing "shared month and year (M d - d, Y)" (is (= (str "Dec 11" date/range-separator "17, 2022") (week-of "2022-12-14T11:19:04")))))) diff --git a/test/metabase/util/formatting/internal/date_builder_test.cljc b/test/metabase/util/formatting/internal/date_builder_test.cljc index 70d60e68b232..d25b5a1d9408 100644 --- a/test/metabase/util/formatting/internal/date_builder_test.cljc +++ b/test/metabase/util/formatting/internal/date_builder_test.cljc @@ -29,7 +29,6 @@ "9:06:19" "2022-06-08T09:06:19Z" [:hour-24-d ":" :minute-dd ":" :second-dd] "7:06 PM" "2022-06-08T19:06:19Z" [:hour-12-d ":" :minute-dd " " :am-pm] "6m19s" "2022-06-08T19:06:19Z" [:minute-d "m" :second-dd "s"])) - (testing "works for strings in Clojure vectors" (are [exp t fmt] (= exp ((builder/->formatter fmt) (u.time/coerce-to-timestamp t))) "2022" "2022-06-08T09:06:19Z" [":year"] @@ -52,7 +51,6 @@ "9:06:19" "2022-06-08T09:06:19Z" [":hour-24-d" ":" ":minute-dd" ":" ":second-dd"] "7:06 PM" "2022-06-08T19:06:19Z" [":hour-12-d" ":" ":minute-dd" " " ":am-pm"] "6m19s" "2022-06-08T19:06:19Z" [":minute-d" "m" ":second-dd" "s"])) - #?(:cljs (testing "works for strings in JS arrays" (are [exp t fmt] (= exp ((builder/->formatter fmt) (u.time/coerce-to-timestamp t))) diff --git a/test/metabase/util/honey_sql_2_test.clj b/test/metabase/util/honey_sql_2_test.clj index 119a53be08f0..5b080780ebc5 100644 --- a/test/metabase/util/honey_sql_2_test.clj +++ b/test/metabase/util/honey_sql_2_test.clj @@ -49,7 +49,6 @@ (testing "Basic format test not including a specific quoting option" (is (= ["SELECT setting"] (sql/format {:select [[:setting]]} {:quoted false})))) - (testing "`:h2` quoting will uppercase and quote the identifier" (is (= ["SELECT \"SETTING\""] (sql/format {:select [[:setting]]} {:dialect :h2}))))) @@ -59,28 +58,23 @@ (is (= ["WHERE name = 'Cam'"] (sql/format {:where [:= :name (h2x/literal "Cam")]} {:quoted false})))) - (testing (str "`literal` should properly escape single-quotes inside the literal string double-single-quotes is how " "to escape them in SQL") (is (= ["WHERE name = 'Cam''s'"] (sql/format {:where [:= :name (h2x/literal "Cam's")]} {:quoted false})))) - (testing "`literal` should only escape single quotes that aren't already escaped -- with two single quotes..." (is (= ["WHERE name = 'Cam''s'"] (sql/format {:where [:= :name (h2x/literal "Cam''s")]} {:quoted false})))) - (testing "...or with a slash" (is (= ["WHERE name = 'Cam\\'s'"] (sql/format {:where [:= :name (h2x/literal "Cam\\'s")]} {:quoted false})))) - (testing "`literal` should escape strings that start with a single quote" (is (= ["WHERE name = '''s'"] (sql/format {:where [:= :name (h2x/literal "'s")]} {:quoted false})))) - (testing "`literal` should handle namespaced keywords correctly" (is (= ["WHERE name = 'ab/c'"] (sql/format {:where [:= :name (h2x/literal :ab/c)]} @@ -91,54 +85,43 @@ (is (= ["SELECT `A`.`B`.`C.D`.`E.F`"] (sql/format {:select [[(h2x/identifier :field "A" :B "C.D" :E.F)]]} {:dialect :mysql})))) - (testing "`identifer` should handle slashes" (is (= ["SELECT `A/B`.`C\\D`.`E/F`"] (sql/format {:select [[(h2x/identifier :field "A/B" "C\\D" :E/F)]]} {:dialect :mysql})))) - (testing "`identifier` should also handle strings with quotes in them (ANSI)" ;; two double-quotes to escape, e.g. "A""B" (is (= ["SELECT \"A\"\"B\""] (sql/format {:select [[(h2x/identifier :field "A\"B")]]} {:dialect :ansi})))) - (testing "`identifier` should also handle strings with quotes in them (MySQL)" ;; double-backticks to escape backticks seems to be the way to do it (is (= ["SELECT `A``B`"] (sql/format {:select [[(h2x/identifier :field "A`B")]]} {:dialect :mysql})))) - (testing "`identifier` shouldn't try to change `lisp-case` to `snake-case` or vice-versa" (is (= ["SELECT \"A-B\".\"c-d\".\"D_E\".\"f_g\""] (sql/format {:select [[(h2x/identifier :field "A-B" :c-d "D_E" :f_g)]]} {:dialect :ansi})))) - (testing "`identifier` should ignore `nil` or empty components." (is (= ["SELECT \"A\".\"B\".\"C\""] (sql/format {:select [[(h2x/identifier :field "A" "B" nil "C")]]} {:dialect :ansi})))) - (testing "`identifier` should handle nested identifiers" (is (= (h2x/identifier :field "A" "B" "C" "D") (h2x/identifier :field "A" (h2x/identifier :field "B" "C") "D"))) - (is (= ["SELECT \"A\".\"B\".\"C\".\"D\""] (sql/format {:select [[(h2x/identifier :field "A" (h2x/identifier :field "B" "C") "D")]]} {:dialect :ansi})))) - (testing "the `identifier` function should unnest identifiers for you so drivers that manipulate `:components` don't need to worry about that" (is (= (h2x/identifier :field "A" "B" "C" "D") (h2x/identifier :field "A" (h2x/identifier :field "B" "C") "D")))) - (testing "the `identifier` function should remove nils so drivers that manipulate `:components` don't need to worry about that" (is (= (h2x/identifier :field "table" "field") (h2x/identifier :field nil "table" "field")))) - (testing "the `identifier` function should convert everything to strings so drivers that manipulate `:components` don't need to worry about that" (is (= (h2x/identifier :field "keyword" "qualified/keyword") (h2x/identifier :field :keyword :qualified/keyword)))) - (testing "Should get formatted correctly inside aliases" ;; Apparently you have to wrap the alias form in ANOTHER vector to make it work -- see ;; https://clojurians.slack.com/archives/C1Q164V29/p1675301408026759 @@ -290,15 +273,12 @@ (is (= ["public" "db" "table" "field"] (h2x/identifier->components (h2x/identifier :field :public :db :table :field)))) - (is (= ["public" "db" "table"] (h2x/identifier->components (h2x/identifier :table :public :db :table)))) - (is (= ["public" "db"] (h2x/identifier->components (h2x/identifier :database :public :db)))) - (is (= ["count"] (h2x/identifier->components (h2x/identifier :field-alias :count))))) diff --git a/test/metabase/util/http_test.clj b/test/metabase/util/http_test.clj index 865c58c66b15..5abf152512c3 100644 --- a/test/metabase/util/http_test.clj +++ b/test/metabase/util/http_test.clj @@ -8,12 +8,10 @@ (is (true? (http/valid-host? :external-only "https://example.com"))) (is (false? (http/valid-host? :external-only "http://localhost"))) (is (false? (http/valid-host? :external-only "http://192.168.1.1")))) - (testing "external-only strategy explicitly" (is (true? (http/valid-host? :external-only "https://example.com"))) (is (false? (http/valid-host? :external-only "http://localhost"))) (is (false? (http/valid-host? :external-only "http://192.168.1.1")))) - (testing "allow-private strategy allows private networks but not localhost" (is (true? (http/valid-host? :allow-private "https://example.com"))) (is (true? (http/valid-host? :allow-private "http://192.168.1.1"))) @@ -22,7 +20,6 @@ (is (false? (http/valid-host? :allow-private "http://localhost"))) (is (false? (http/valid-host? :allow-private "http://127.0.0.1"))) (is (false? (http/valid-host? :allow-private "http://169.254.1.1")))) - (testing "allow-all strategy allows everything" (is (true? (http/valid-host? :allow-all "https://example.com"))) (is (true? (http/valid-host? :allow-all "http://localhost"))) diff --git a/test/metabase/util/i18n/impl_test.clj b/test/metabase/util/i18n/impl_test.clj index 2a55abf9912c..489b14a06308 100644 --- a/test/metabase/util/i18n/impl_test.clj +++ b/test/metabase/util/i18n/impl_test.clj @@ -41,11 +41,9 @@ (testing (pr-str (list 'locale x)) (is (= (Locale/forLanguageTag (if language "en-US" "en")) (i18n.impl/locale x))))) - (testing "If something is already a Locale, `locale` should act as an identity fn" (is (= (Locale/forLanguageTag "en-US") (i18n.impl/locale #locale "en-US"))))) - (testing "nil" (is (= nil (i18n.impl/locale nil))))) @@ -109,20 +107,16 @@ (testing "Should be able to translate stuff" (is (= "¡Tu base de datos ha sido añadida!" (i18n.impl/translate "es" "Your database has been added!")))) - (testing "should be able to use language-country Locale if available" (is (= "Está muy bien, gracias" (i18n.impl/translate "es-MX" "I''m good thanks")))) - (testing "should fall back from `language-country` Locale to `language`" (is (= "¡Tu base de datos ha sido añadida!" (i18n.impl/translate "es-MX" "Your database has been added!")))) - (testing "Should fall back to English if no bundles/translations exist" (is (= "abc 123 wow" (i18n.impl/translate "ok" "abc 123 wow") (i18n.impl/translate "es" "abc 123 wow")))) - (testing "format strings with arguments" (is (= "deben tener 140 caracteres o menos" (i18n.impl/translate "es" "must be {0} characters or less" [140])))))) @@ -132,7 +126,6 @@ (testing "Should fall back to original format string if translated one is busted" (is (= "Bad translation 100" (i18n.impl/translate "ba-DD" "Bad translation {0}" [100])))) - (testing "if the original format string is busted, should just return format-string as-is (better than nothing)" (is (= "Bad original {a}" (i18n.impl/translate "ba-DD" "Bad original {a}" [100])))))) diff --git a/test/metabase/util/i18n/plural_test.clj b/test/metabase/util/i18n/plural_test.clj index d4ab369511c6..4a2776084b60 100644 --- a/test/metabase/util/i18n/plural_test.clj +++ b/test/metabase/util/i18n/plural_test.clj @@ -76,7 +76,6 @@ "1 > 2 ? 1 : 1 && 0" 0 "1 > 2 ? 0 : 1 < 1 ? 1 : 2" 2 "1 < 2 ? 1 < 3 ? 1 : 2 : 3" 1)) - (testing "Error cases" (are [formula] (insta/failure? (compute formula)) ;; Empty formulas @@ -108,13 +107,11 @@ 0 1 1 0 2 1)) - (testing "French" (are [n expected] (= expected (compute "n > 1" n)) 0 0 1 0 2 1)) - (testing "Latvian" (are [n expected] (= expected (compute "n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2" n)) 0 2 @@ -122,13 +119,11 @@ 11 1 21 0 111 1)) - (testing "Irish" (are [n expected] (= expected (compute "n==1 ? 0 : n==2 ? 1 : 2" n)) 1 0 2 1 3 2)) - (testing "Romanian" (are [n expected] (= expected (compute "n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2" n)) 0 1 @@ -138,7 +133,6 @@ 20 2 100 2 101 1)) - (testing "Russian, Ukrainian, Serbian" (are [n expected] (= expected (compute (str "n%10==1 && n%100!=11 ? 0 :" "n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2") @@ -151,7 +145,6 @@ 102 1 109 2 110 2)) - (testing "Czech, Slovak" (are [n expected] (= expected (compute "(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2" n)) 0 2 @@ -160,7 +153,6 @@ 3 1 4 1 5 2)) - (testing "Polish" (are [n expected] (= expected (compute (str "n==1 ? 0 :" "n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2") diff --git a/test/metabase/util/i18n_test.clj b/test/metabase/util/i18n_test.clj index a32aa60fb459..eb8133f44c08 100644 --- a/test/metabase/util/i18n_test.clj +++ b/test/metabase/util/i18n_test.clj @@ -26,17 +26,14 @@ (mt/with-temporary-setting-values [site-locale nil] (is (= "must be 140 characters or less" (f))))) - (testing "If system locale is set but user locale is not, should use system locale" (mt/with-temporary-setting-values [site-locale "es"] (is (= "deben tener 140 caracteres o menos" (f))))) - (testing "Should use user locale if set" (mt/with-user-locale "es" (is (= "deben tener 140 caracteres o menos" (f))) - (testing "...even if system locale is set" (mt/with-temporary-setting-values [site-locale "en"] (is (= "deben tener 140 caracteres o menos" @@ -58,12 +55,10 @@ (mt/with-temporary-setting-values [site-locale nil] (is (= "must be 140 characters or less" (f))))) - (testing "Should use system locale if set" (mt/with-temporary-setting-values [site-locale "es"] (is (= "deben tener 140 caracteres o menos" (f))) - (testing "...even if user locale is set" (mt/with-user-locale "en" (is (= "deben tener 140 caracteres o menos" @@ -82,21 +77,16 @@ (mt/with-temporary-setting-values [site-locale nil] (is (= "0 tables" (f 0))) - (is (= "1 table" (f 1))) - (is (= "2 tables" (f 2))))) - (testing "should use user locale if set" (mt/with-user-locale "es" (is (= "0 tablas" (f 0))) - (is (= "1 tabla" (f 1))) - (is (= "2 tablas" (f 2))))))))) @@ -120,21 +110,16 @@ (mt/with-temporary-setting-values [site-locale nil] (is (= "0 tables" (f 0))) - (is (= "1 table" (f 1))) - (is (= "2 tables" (f 2))))) - (testing "Should use system locale if set" (mt/with-temporary-setting-values [site-locale "es"] (is (= "0 tablas" (f 0))) - (is (= "1 tabla" (f 1))) - (is (= "2 tablas" (f 2))))))))) @@ -154,7 +139,6 @@ AssertionError #"expects 2 args, got 1" (#'i18n/validate-number-of-args "{0} {1}" [0])))) - (testing "too many args" (is (thrown? clojure.lang.Compiler$CompilerException @@ -163,7 +147,6 @@ AssertionError #"expects 2 args, got 3" (#'i18n/validate-number-of-args "{0} {1}" [0 1 2])))) - (testing "Missing format specifiers (e.g. {1} but no {0})" (testing "num args match num specifiers" (is (thrown? @@ -173,7 +156,6 @@ AssertionError #"missing some \{\} placeholders\. Expected \{0\}, \{1\}" (#'i18n/validate-number-of-args "{1}" [0])))) - (testing "num args match num specifiers if none were missing" (is (thrown? clojure.lang.Compiler$CompilerException @@ -182,7 +164,6 @@ AssertionError #"missing some \{\} placeholders\. Expected \{0\}, \{1\}" (#'i18n/validate-number-of-args "{1}" [0 1])))))) - (testing "The number of args is still validated if the first argument is a `str` form" (is (thrown? clojure.lang.Compiler$CompilerException @@ -191,7 +172,6 @@ AssertionError #"expects 2 args, got 1" (#'i18n/validate-number-of-args '(str "{0}" "{1}") [0])))) - (testing "`trsn` and `trun` should validate that they are being called with at most one arg\n" (is (thrown? clojure.lang.Compiler$CompilerException @@ -200,7 +180,6 @@ AssertionError #"only supports a single \{0\} placeholder" (#'i18n/validate-n "{1}" "{1}"))) - (is (thrown? clojure.lang.Compiler$CompilerException (walk/macroexpand-all `(i18n/trsn "{0} {1}" "{0} {1}" n)))) diff --git a/test/metabase/util/log_test.clj b/test/metabase/util/log_test.clj index 322dbd9d3f59..e74616606e98 100644 --- a/test/metabase/util/log_test.clj +++ b/test/metabase/util/log_test.clj @@ -25,25 +25,20 @@ (is (= {"mb-user-id" "123" "mb-action" "test"} (get-context)) "Context should be set inside macro")) - (is (= original-context (get-context)) "Context should be reset after macro")) - (testing "with-context should handle nested contexts" (log/with-context {:outer "value" :empty "" :false false} (is (= {"mb-outer" "value" "mb-empty" "" "mb-false" "false"} (get-context)) "Outer context should be set") - (log/with-context {:inner "nested"} (is (= {"mb-outer" "value" "mb-inner" "nested" "mb-empty" "" "mb-false" "false"} (get-context)) "Inner context should replace outer context")) - (is (= {"mb-outer" "value" "mb-empty" "" "mb-false" "false"} (get-context)) "Outer context should be restored after nested macro"))) - (try (log/with-context {:error "test"} (throw (Exception. "Test exception"))) diff --git a/test/metabase/util/malli/defn_test.clj b/test/metabase/util/malli/defn_test.clj index c7ac10652873..cb88a61a3bfa 100644 --- a/test/metabase/util/malli/defn_test.clj +++ b/test/metabase/util/malli/defn_test.clj @@ -50,7 +50,6 @@ (try (bar {}) (catch Exception e (ex-data e)))) "when we pass bar an invalid shape um/defn throws")) - (testing "invalid output" (is (=? {:humanized {:x ["should be an int, got: \"3\""] :y ["missing required key, got: nil"]}} @@ -86,7 +85,6 @@ (deftest ^:parallel mu-defn-docstrings (testing "docstrings are preserved" (is (str/ends-with? (:doc (meta #'boo)) "something very important to remember goes here"))) - (testing "no schemas given should work" (is (= "Inputs: []\n Return: :any" (:doc (meta #'qux-1)))) @@ -97,7 +95,6 @@ "" " Original docstring."]) (:doc (meta #'qux-2))))) - (testing "no return schemas given should work" (is (= "Inputs: [x :- :int]\n Return: :any" (:doc (meta #'qux-3)))) @@ -108,7 +105,6 @@ "" " Original docstring."]) (:doc (meta #'qux-4))))) - (testing "no input schemas given should work" (is (= "Inputs: []\n Return: :int" (:doc (meta #'qux-5)))) @@ -119,7 +115,6 @@ "" " Original docstring."]) (:doc (meta #'qux-6))))) - (testing "multi-arity, and varargs doc strings should work" (is (= (str/join "\n" ;;v---doc inserts 2 spaces here, it's not misaligned! diff --git a/test/metabase/util/malli/fn_test.clj b/test/metabase/util/malli/fn_test.clj index 0e51effbb013..1016f0771957 100644 --- a/test/metabase/util/malli/fn_test.clj +++ b/test/metabase/util/malli/fn_test.clj @@ -61,10 +61,8 @@ '(describe-temporal-unit :- :string ([] (describe-temporal-unit 1 nil)) - ([unit] (describe-temporal-unit 1 unit)) - ([n :- :int unit :- [:maybe :keyword]] (str n \space (or unit :day))))))))) @@ -228,7 +226,6 @@ (metabase.util.malli.fn/validate-output {:fn-name 'my-plus} :int)) (catch java.lang.Exception error (throw (metabase.util.malli.fn/fixup-stacktrace error))))))) - (macroexpand form))) (is (= [:=> [:cat :int :int [:* :int]] @@ -267,7 +264,6 @@ (metabase.util.malli.fn/validate-output {:fn-name 'my-plus} :map)) (catch java.lang.Exception error (throw (metabase.util.malli.fn/fixup-stacktrace error))))))) - (macroexpand form))) (is (= [:=> [:cat :int :int [:* :any]] diff --git a/test/metabase/util/malli/schema_test.clj b/test/metabase/util/malli/schema_test.clj index a96b5eb01ed7..68801cc1face 100644 --- a/test/metabase/util/malli/schema_test.clj +++ b/test/metabase/util/malli/schema_test.clj @@ -61,7 +61,6 @@ (doseq [case failed-cases] (testing (format "case: %s should fail" (pr-str case)) (is (false? (mr/validate schema case))))) - (doseq [case success-cases] (testing (format "case: %s should success" (pr-str case)) (is (true? (mr/validate schema case)))))))) diff --git a/test/metabase/util/malli_test.cljc b/test/metabase/util/malli_test.cljc index 9edf392d6b5d..47355a64db39 100644 --- a/test/metabase/util/malli_test.cljc +++ b/test/metabase/util/malli_test.cljc @@ -20,17 +20,13 @@ [:fn less-than-four-fxn] (deferred-tru "Special Number that has to be less than four description") (deferred-tru "Special Number that has to be less than four error"))] - (is (= [(deferred-tru "Special Number that has to be less than four error")] (me/humanize (mr/explain special-lt-4-schema 8)))) - (is (= ["Special Number that has to be less than four error, received: 8"] (me/humanize (mr/explain special-lt-4-schema 8) {:wrap #'mu/humanize-include-value}))) - (testing "should be user-localized" (is (= "Special Number that has to be less than four description" (umd/describe special-lt-4-schema))) - (mt/with-mock-i18n-bundles! {"es" {:messages {"Special Number that has to be less than four description" "Número especial que tiene que ser menos de cuatro descripción" @@ -40,20 +36,16 @@ (mt/with-user-locale "es" (is (= "Número especial que tiene que ser menos de cuatro descripción" (umd/describe special-lt-4-schema))) - (is (= ["Número especial que tiene que ser menos de cuatro errores, recibió: 8"] (me/humanize (mr/explain special-lt-4-schema 8) {:wrap #'mu/humanize-include-value})))))))) - (testing "inner schema" (let [special-lt-4-schema [:map [:ltf-key (mu/with-api-error-message [:fn less-than-four-fxn] (deferred-tru "Special Number that has to be less than four"))]]] (is (= {:ltf-key ["missing required key"]} (me/humanize (mr/explain special-lt-4-schema {})))) - (is (= {:ltf-key [(deferred-tru "Special Number that has to be less than four")]} (me/humanize (mr/explain special-lt-4-schema {:ltf-key 8})))) - (is (= "map where {:ltf-key -> }" (umd/describe special-lt-4-schema)))))))) diff --git a/test/metabase/util/match_test.cljc b/test/metabase/util/match_test.cljc index 2153afdcc2b4..58ce8267068c 100644 --- a/test/metabase/util/match_test.cljc +++ b/test/metabase/util/match_test.cljc @@ -18,7 +18,6 @@ [:field 3 {:source-field 2}]] (match/match-many [[:field 1 nil] [:field 3 {:source-field 2}]] [(:or :field) & _] &match))) - (t/is (= [[:field 10 nil] [:field 20 nil]] (match/match-many {:query {:filter [:= @@ -39,7 +38,6 @@ ;; return just the dest IDs of Fields in a fk-> clause (match/match-many a-query [:field dest-id {:source-field (_ :guard integer?)}] (inc dest-id)))) - (t/is (= [10 20] (match/match-many (:breakout a-query) [:field id nil] id))))) @@ -54,7 +52,6 @@ (let [a-field-id 2] (match/match-many {:fields [[:field 1 nil] [:field 2 nil]]} [:field (id :guard (= id a-field-id)) _] &match))))) - (t/testing "ok, if for some reason we can't use `:guard` in the pattern will `match-many` filter out nil results?" (t/is (= [2] (match/match-many {:fields [[:field 1 nil] [:field 2 nil]]} @@ -96,7 +93,6 @@ [:= [:field 2 nil] 5000]]} (&match :guard integer?) &match)))) - (t/testing "can we use a predicate and bind the match at the same time?" (t/is (= [2 4001 3 5001] (match/match-many {:filter [:and @@ -112,7 +108,6 @@ (match/match-many x (m :guard (and (map? m) (string? (:source-table m)))) (:source-table m))))) - (t/is (= ["card__1847"] (let [x {:source-table "card__1847"}] (match/match-many x @@ -126,7 +121,6 @@ (match/match-many x (m :guard (and (map? m) (string? (:source-table m)))) (:source-table m))))) - (t/is (= ["card__1847"] (let [x [{:source-table "card__1847"}]] (match/match-many x @@ -289,7 +283,6 @@ (t/is (= "a=1 b=2 rest=[3 4 5]" (match/match-one [1 2 3 4 5] [a b & (rst :guard (> (count rst) 2))] (str "a=" a " b=" b " rest=" rst)))) - (t/testing "Edge cases" (t/testing "Empty collections" (t/is (= :empty-vec (match/match-one [] @@ -386,7 +379,6 @@ [(_ :guard :b)] 2))) (t/is (= :ok (match/match-one [3] [(_ :guard #{1 2 3})] :ok))) - #?(:clj (t/is (thrown? clojure.lang.Compiler$CompilerException (eval '(match/match-one [1] [(a :guard #(odd? %))] a))))) @@ -414,11 +406,9 @@ (t/is (= [100] (match/match-many [[1 2 3] [4 5 6]] (_ :guard keyword?) &match _ 100))) - (t/testing "absent of matches returns nil" (t/is (= nil (match/match-many [[1 2 3] [4 5 6]] (_ :guard keyword?) &match)))) - (t/testing "nils aren't recorded into the result" (t/is (= [15] (match/match-many [[1 2 3] [4 5 6]] [a b c] (when (> a 1) diff --git a/test/metabase/util/ordered_hierarchy_test.clj b/test/metabase/util/ordered_hierarchy_test.clj index 9fc79295b221..5372700b3f8f 100644 --- a/test/metabase/util/ordered_hierarchy_test.clj +++ b/test/metabase/util/ordered_hierarchy_test.clj @@ -40,7 +40,6 @@ :obtuse-triangle :triangle] (vec (ordered-hierarchy/sorted-tags polygons))))) - (testing "Hiccup structures are translated into the expected graph structure" (is (= {:trapezoid [:quadrilateral] :isosceles-trapezoid [:trapezoid] diff --git a/test/metabase/util/performance_test.cljc b/test/metabase/util/performance_test.cljc index 5500c32188c8..131acc402f36 100644 --- a/test/metabase/util/performance_test.cljc +++ b/test/metabase/util/performance_test.cljc @@ -87,7 +87,6 @@ y nil] [x y]) #_=> []) - (are [i o] (= o i) (with-out-str (perf/doseq [x (range 3) @@ -168,23 +167,18 @@ (deftest test-update-keys (is (= {"a" 1 "b" 2 "c" 3} (perf/update-keys {:a 1 :b 2 :c 3} name))) (is (= {} (perf/update-keys nil keyword))) - (testing "no changes" (let [original {:a 1 :b 2 :c 3} result (perf/update-keys original identity)] (is (identical? original result)))) - (testing "empty" (is (identical? {} (perf/update-keys {} str)))) - (testing "partial key transformation" (is (= {:keep-me 1 :changed 2} (perf/update-keys {:keep-me 1 :change-me 2} #(if (= % :change-me) :changed %))))) - (testing "key collision - later keys should overwrite" (is (= {:same 20} (perf/update-keys {:a 10 :b 20} (constantly :same))))) - (testing "f returns nil keys" (is (= {nil 2} (perf/update-keys {:a 1 :b 2} (constantly nil)))))) @@ -195,24 +189,19 @@ (is (= 42 (perf/get-in {:key 42} [:key]))) (is (= {:a 1} (perf/get-in {:a 1} []))) (is (nil? (perf/get-in nil [:a :b])))) - (testing "missing keys return nil" (is (nil? (perf/get-in {:a {:b 3}} [:a :c]))) (is (nil? (perf/get-in {:a 1} [:x :y :z])))) - (testing "with not-found value" (is (= :default (perf/get-in {:a {:b 3}} [:a :c] :default))) (is (nil? (perf/get-in {:a {:b 3}} [:a :c] nil))) (is (nil? (perf/get-in {:a {:b nil}} [:a :b] :something-else)))) - (testing "nil values vs missing keys" (is (nil? (perf/get-in {:a {:b nil}} [:a :b]))) (is (= :default (perf/get-in {:a {:b nil}} [:a :c] :default)))) - (testing "works with vectors" (is (= 2 (perf/get-in [[1 2] [3 4]] [0 1]))) (is (= 30 (perf/get-in {:items [10 20 30]} [:items 2])))) - (testing "partial path exists" (is (nil? (perf/get-in {:a 1} [:a :b]))) (is (= :fallback (perf/get-in {:a "not-a-map"} [:a :b] :fallback))))) @@ -238,23 +227,23 @@ #?(:clj (defspec mapv-single-coll-equivalence 100 (prop/for-all [coll (mg/generator [:sequential :int])] - (= (mapv str coll) - (perf/mapv str coll))))) + (= (mapv str coll) + (perf/mapv str coll))))) #?(:clj (defspec mapv-two-coll-equivalence 100 (prop/for-all [c1 (mg/generator [:sequential :int]) c2 (mg/generator [:sequential :int])] - (= (mapv str c1 c2) - (perf/mapv str c1 c2))))) + (= (mapv str c1 c2) + (perf/mapv str c1 c2))))) #?(:clj (defspec mapv-three-coll-equivalence 100 (prop/for-all [c1 (mg/generator [:sequential :int]) c2 (mg/generator [:sequential :int]) c3 (mg/generator [:sequential :int])] - (= (mapv str c1 c2 c3) - (perf/mapv str c1 c2 c3))))) + (= (mapv str c1 c2 c3) + (perf/mapv str c1 c2 c3))))) #?(:clj (defspec mapv-four-coll-equivalence 100 @@ -262,67 +251,67 @@ c2 (mg/generator [:sequential :int]) c3 (mg/generator [:sequential :int]) c4 (mg/generator [:sequential :int])] - (= (mapv str c1 c2 c3 c4) - (perf/mapv str c1 c2 c3 c4))))) + (= (mapv str c1 c2 c3 c4) + (perf/mapv str c1 c2 c3 c4))))) #?(:clj (defspec smallest-count-does-not-over-realize-2-colls 100 (prop/for-all [short-len (mg/generator [:int {:min 0 :max 33}]) extra-len (mg/generator [:int {:min 34 :max 100}])] - (let [short-coll (vec (range short-len)) - trap-coll (trap-seq (+ short-len extra-len))] - (= (#'perf/smallest-count short-coll trap-coll) - (min short-len extra-len)))))) + (let [short-coll (vec (range short-len)) + trap-coll (trap-seq (+ short-len extra-len))] + (= (#'perf/smallest-count short-coll trap-coll) + (min short-len extra-len)))))) #?(:clj (defspec mapv-does-not-over-realize-2-colls 1000 (prop/for-all [short-len (mg/generator [:int {:min 0 :max 50}]) extra-len (mg/generator [:int {:min 34 :max 100}])] - (let [short-coll (vec (range short-len)) - trap-coll (trap-seq (+ short-len extra-len))] - (= (mapv vector short-coll trap-coll) - (perf/mapv vector short-coll trap-coll)))))) + (let [short-coll (vec (range short-len)) + trap-coll (trap-seq (+ short-len extra-len))] + (= (mapv vector short-coll trap-coll) + (perf/mapv vector short-coll trap-coll)))))) #?(:clj (defspec mapv-does-not-over-realize-3-colls 1000 (prop/for-all [short-len (mg/generator [:int {:min 0 :max 33}]) extra-len (mg/generator [:int {:min 34 :max 100}])] - (let [short-coll (vec (range short-len)) - trap-coll (trap-seq (+ short-len extra-len))] - (= (mapv vector short-coll trap-coll trap-coll) - (perf/mapv vector short-coll trap-coll trap-coll)))))) + (let [short-coll (vec (range short-len)) + trap-coll (trap-seq (+ short-len extra-len))] + (= (mapv vector short-coll trap-coll trap-coll) + (perf/mapv vector short-coll trap-coll trap-coll)))))) #?(:clj (defspec mapv-does-not-over-realize-4-colls 1000 (prop/for-all [short-len (mg/generator [:int {:min 0 :max 33}]) extra-len (mg/generator [:int {:min 34 :max 100}])] - (let [short-coll (vec (range short-len)) - trap-coll (trap-seq (+ short-len extra-len))] - (= (mapv vector short-coll trap-coll trap-coll trap-coll) - (perf/mapv vector short-coll trap-coll trap-coll trap-coll)))))) + (let [short-coll (vec (range short-len)) + trap-coll (trap-seq (+ short-len extra-len))] + (= (mapv vector short-coll trap-coll trap-coll trap-coll) + (perf/mapv vector short-coll trap-coll trap-coll trap-coll)))))) #?(:clj (defspec mapv-handles-infinite-seqs-2-colls 1000 (prop/for-all [coll (mg/generator [:vector {:min 0 :max 50} :int])] - (= (mapv vector coll (range)) - (perf/mapv vector coll (range)))))) + (= (mapv vector coll (range)) + (perf/mapv vector coll (range)))))) #?(:clj (defspec mapv-handles-infinite-seqs-3-colls 1000 (prop/for-all [coll (mg/generator [:vector {:min 0 :max 50} :int])] - (= (mapv vector coll (range) (iterate inc 0)) - (perf/mapv vector coll (range) (iterate inc 0)))))) + (= (mapv vector coll (range) (iterate inc 0)) + (perf/mapv vector coll (range) (iterate inc 0)))))) #?(:clj (defspec mapv-handles-infinite-seqs-4-colls 1000 (prop/for-all [coll (mg/generator [:vector {:min 0 :max 50} :int])] - (= (mapv vector coll (range) (iterate inc 0) (repeat 1)) - (perf/mapv vector coll (range) (iterate inc 0) (repeat 1)))))) + (= (mapv vector coll (range) (iterate inc 0) (repeat 1)) + (perf/mapv vector coll (range) (iterate inc 0) (repeat 1)))))) #?(:clj (defspec mapv-boundary-32-equivalence 1000 (prop/for-all [n (mg/generator [:int {:min 28 :max 36}])] - (let [c1 (vec (range n)) - c2 (vec (range n))] - (= (mapv + c1 c2) - (perf/mapv + c1 c2)))))) + (let [c1 (vec (range n)) + c2 (vec (range n))] + (= (mapv + c1 c2) + (perf/mapv + c1 c2)))))) diff --git a/test/metabase/util/queue_test.clj b/test/metabase/util/queue_test.clj index a3b05eefe666..6e425ff59ab5 100644 --- a/test/metabase/util/queue_test.clj +++ b/test/metabase/util/queue_test.clj @@ -29,12 +29,10 @@ (queue/blocking-put! queue timeout-ms {:thread "back", :payload e}))) run! (fn [f] (future (f)))] - (run! background-fn) (future (dotimes [_ realtime-threads] (run! realtime-fn))) - (let [processed (volatile! [])] (try (while true @@ -64,24 +62,19 @@ (simulate-queue! queue :backfill-events backfill-events :realtime-events realtime-events)] - (testing "We processed all the events that were enqueued" (is (= (+ (count backfill-events) sent) (count processed)))) - (testing "No items are skipped" (is (zero? skipped))) - (testing "Some items are dropped" (is (pos? dropped))) - (let [expected-events (set (concat backfill-events realtime-events)) processed-events (set processed)] (testing "All expected events are processed" (is (zero? (count (set/difference expected-events processed-events))))) (testing "There are no unexpected events processed" (is (zero? (count (set/difference processed-events expected-events)))))) - (testing "The realtime events are processed in order" (mt/ordered-subset? realtime-events processed)))) @@ -149,13 +142,11 @@ thread-name "queue-test-listener-0"] (is (not (thread-name-running? thread-name))) (is (not (queue/listener-exists? listener-name))) - (queue/listen! listener-name queue (fn [batch] (swap! items-handled + (count batch)) (reset! last-batch batch)) {:max-next-ms 5}) (is (thread-name-running? thread-name)) (is (queue/listener-exists? listener-name)) - (is (nil? (queue/listen! listener-name queue (fn [batch] (throw (ex-info "Second listener with the same name cannot be created" {:batch batch}))) {:max-next-ms 5}))) @@ -164,20 +155,16 @@ (await-test-while (zero? @items-handled) (is (= 1 @items-handled)) (is (= ["a"] @last-batch))) - (queue/put-with-delay! queue 0 "b") (queue/put-with-delay! queue 0 "c") (queue/put-with-delay! queue 0 "d") (await-test-while (< @items-handled 4) (is (= 4 @items-handled)) (is (some #{"d"} @last-batch))) - (finally (queue/stop-listening! listener-name))) - (await-test-while (thread-name-running? thread-name)) (is (not (queue/listener-exists? listener-name))) - ; additional calls to stop are no-ops (is (nil? (queue/stop-listening! listener-name)))))) @@ -204,13 +191,11 @@ (await-test-while (zero? @result-count) (is (= 0 @error-count)) (is (= 1 @result-count))) - (queue/put-with-delay! queue 0 "err") (await-test-while (zero? @error-count) (is (= 1 @error-count)) (is (= 1 @result-count)) (is (= "Test Error" (.getMessage ^Exception @last-error)))) - (finally (queue/stop-listening! listener-name)))))) @@ -226,7 +211,6 @@ (is (not (thread-name-running? thread-name-0))) (is (not (thread-name-running? thread-name-1))) (is (not (thread-name-running? thread-name-2))) - (queue/listen! listener-name queue (fn [batch] (is (<= (count batch) 10)) (count batch)) @@ -238,14 +222,11 @@ (is (thread-name-running? thread-name-0)) (is (thread-name-running? thread-name-1)) (is (thread-name-running? thread-name-2)) - (dotimes [i 100] (queue/put-with-delay! queue 0 i)) - (await-test-while (< @batches-handled 100) (is (= 100 @batches-handled)) (is (contains? @handlers-used listener-name))) - (finally (queue/stop-listening! listener-name))) (await-test-while (or (thread-name-running? thread-name-0) @@ -270,16 +251,13 @@ (queue/put-with-delay! queue 0 "boom") (await-test-while (zero? @call-count) (is (= 1 @call-count))) - ;; Thread should still be alive (is (thread-name-running? thread-name) "Listener thread should survive an AssertionError") - ;; Second message should still be processed (queue/put-with-delay! queue 0 "ok") (await-test-while (< @call-count 2) (is (= 2 @call-count))) - (finally (queue/stop-listening! listener-name)))))) @@ -307,18 +285,14 @@ (queue/put-with-delay! queue 0 "fail") (await-test-while (not @err-handler-ran) (is @err-handler-ran)) - ;; Wait for restart backoff (initial-restart-backoff-ms = 500ms) plus margin (Thread/sleep 1000) - ;; Thread should be alive again after restart (is (thread-name-running? thread-name) "Listener thread should restart after err-handler throws an Error") - ;; Verify second message is processed normally (queue/put-with-delay! queue 0 "ok") (await-test-while (< @call-count 2) (is (= 2 @call-count))) - (finally (queue/stop-listening! listener-name)))))) diff --git a/test/metabase/util/retry_test.clj b/test/metabase/util/retry_test.clj index 2e91acf7e108..240a1814a3df 100644 --- a/test/metabase/util/retry_test.clj +++ b/test/metabase/util/retry_test.clj @@ -37,7 +37,6 @@ :retry-if (fn [val _] (odd? val)) :initial-interval-millis 1) (f)))))) - (testing "recovery impossible" (let [f (constantly 1)] (is (= 1 (retry/with-retry (assoc (retry/retry-configuration) diff --git a/test/metabase/util/string_test.clj b/test/metabase/util/string_test.clj index 348377163848..c2afb18741b1 100644 --- a/test/metabase/util/string_test.clj +++ b/test/metabase/util/string_test.clj @@ -10,23 +10,19 @@ (testing "works correctly in general case" (is (= "qwer...uiop" (u.str/mask "qwertyuiop")))) - (testing "works correctly with short strings" (is (= "qw..." (u.str/mask "qwer"))) (is (= "q..." (u.str/mask "q")))) - (testing "does not throw errors for empty values" (is (= "" (u.str/mask ""))) (is (= nil (u.str/mask nil)))) - (testing "works with custom start-limit" (is (= "abcd-efgh...-end" (u.str/mask "abcd-efgh-ijkl-end" 9)))) - (testing "works with custom end-limit" (is (= "ab...ra" (u.str/mask "abracadabra" 2 2)))))) diff --git a/test/metabase/util/time_test.cljc b/test/metabase/util/time_test.cljc index 22d1a53b83c0..4ddacfad1638 100644 --- a/test/metabase/util/time_test.cljc +++ b/test/metabase/util/time_test.cljc @@ -117,12 +117,10 @@ "2022-01-01T00:00:00" 2022 "year" "1954-01-01T00:00:00" 1954 "year" "2044-01-01T00:00:00" 2044 "year"))) - (testing "numbers with no unit are parsed as year numbers" (are [exp-str input] (same? (from-zulu exp-str) (shared.ut/coerce-to-timestamp input {})) "1950-01-01T00:00:00Z" 1950 "2015-01-01T00:00:00Z" 2015)) - (testing "strings" (testing "with unit=day-of-week get parsed as eg. Mon" (with-redefs [internal/now (fn [] (from test-epoch))] @@ -135,7 +133,6 @@ "2022-12-16T00:00:00" "Fri" "2022-12-17T00:00:00" "Sat" "2022-12-18T00:00:00" "Sun"))) - (testing "with unit != day-of-week" (testing "and a time offset are parsed in that offset" (are [exp-str input] (same-instant? (from-zulu exp-str) (shared.ut/coerce-to-timestamp input {})) @@ -145,7 +142,6 @@ (testing "and no time offset are assumed to be UTC" (is (same? (from-zulu "2022-12-14T13:37:45Z") (shared.ut/coerce-to-timestamp "2022-12-14T13:37:45" {})))))) - (testing "existing date-time values are simply returned" (are [value] (let [t (shared.ut/coerce-to-timestamp value)] (same? t (shared.ut/coerce-to-timestamp t))) "2022-12-12T00:00:00" @@ -183,7 +179,6 @@ (deftest to-range-test (doseq [[exp-from exp-to date unit] [["2022-01-01T00:00:00Z" "2022-12-31T23:59:59.999Z" "2022-08-19T00:00:00" "year"] - ["2022-08-01T00:00:00Z" "2022-08-31T23:59:59.999Z" "2022-08-19T00:00:00" "month"] ; 31 days in August ["2022-02-01T00:00:00Z" "2022-02-28T23:59:59.999Z" "2022-02-19T00:00:00" "month"] ; 28 days in regular February ["2020-02-01T00:00:00Z" "2020-02-29T23:59:59.999Z" "2020-02-19T00:00:00" "month"] ; 29 days in leap-year February @@ -225,11 +220,9 @@ "09:26:45.000" "09:26:45-08:00" "09:26:00.000" "09:26-08:00" "19:26:00.000" "19:26-08:00")) - (testing "Day.js and LocalTime values are simply returned" (let [t (time-from "09:29")] (is (= t (shared.ut/coerce-to-time t))))) - (testing "numbers are treated as Unix timestamps" (is (thrown-with-msg? #?(:clj Exception :cljs js/Error) #"Unknown input to coerce-to-time; expecting a string" @@ -244,7 +237,6 @@ "6" :week-of-year "Q1" :quarter-of-year "Feb 8, 2023" nil) - (are [exp u] (= exp (shared.ut/format-unit "2023-02-08" u "fr")) "mercredi" :day-of-week "févr." :month-of-year @@ -262,7 +254,6 @@ "6" :week-of-year "Q1" :quarter-of-year "Feb 8, 2023" nil) - (are [exp u] (= exp (shared.ut/format-unit (from-local-date "2023-02-08") u "fr")) "mercredi" :day-of-week "févr." :month-of-year @@ -271,13 +262,10 @@ "6" :week-of-year "Q1" :quarter-of-year "Feb 8, 2023" nil) - (is (= "12:00 PM" (shared.ut/format-unit "12:00:00.000" nil))) (is (= "12:00 PM" (shared.ut/format-unit (from-local-time "12:00:00.000") nil))) - (is (= "Oct 3, 2023, 1:30 PM" (shared.ut/format-unit "2023-10-03T13:30:00" nil))) (is (= "Oct 3, 2023, 1:30 PM" (shared.ut/format-unit (from-local "2023-10-03T13:30:00") nil))) - (is (= "30" (shared.ut/format-unit "2023-10-03T13:30:00" :minute-of-hour))) (is (= "1 PM" (shared.ut/format-unit "2023-10-03T13:30:00" :hour-of-day))) (is (= "30" (shared.ut/format-unit 30 :minute-of-hour))) diff --git a/test/metabase/util_test.cljc b/test/metabase/util_test.cljc index 2f01318071a1..dd58240ee57d 100644 --- a/test/metabase/util_test.cljc +++ b/test/metabase/util_test.cljc @@ -270,7 +270,6 @@ (u/kebab->snake-keys {:user-id 1 :profile-id "abc" :nested {:still-kebab-case true}}))) - (testing "preserves camelCase keys" (is (= {:userId 1 :profileId "abc" @@ -287,7 +286,6 @@ (u/deep-kebab->snake-keys {:user-id 1 :profile-id "abc" :nested {:inner-key {:deeply-nested true}}})))) - (testing "preserves camelCase throughout the structure" (is (= {:userId 1 :nested {:innerKey {:deeplyNested true @@ -295,7 +293,6 @@ (u/deep-kebab->snake-keys {:userId 1 :nested {:innerKey {:deeplyNested true :kebab-converted "yes"}}})))) - (testing "works with vectors and other collections" (is (= [{:user_id 1} {:user_id 2}] (u/deep-kebab->snake-keys [{:user-id 1} {:user-id 2}]))) @@ -402,14 +399,12 @@ (testing "nil and empty maps return empty maps" (is (= {} (u/normalize-map nil))) (is (= {} (u/normalize-map {})))) - (let [exp {:kebab-key 1 :snake-key 2 :camel-key 3}] (testing "Clojure maps have their keys normalized" (is (= exp (u/normalize-map {:kebab-key 1 :snake_key 2 :camelKey 3}))) (is (= exp (u/normalize-map {"kebab-key" 1 "snake_key" 2 "camelKey" 3})))) - #?(:cljs (testing "JS objects get turned into Clojure maps" (is (= exp (u/normalize-map #js {"kebab-key" 1 "snake_key" 2 "camelKey" 3}))))))) diff --git a/test/metabase/version/settings_test.clj b/test/metabase/version/settings_test.clj index d869924e60ef..4de239dc8c70 100644 --- a/test/metabase/version/settings_test.clj +++ b/test/metabase/version/settings_test.clj @@ -27,7 +27,6 @@ (is (not (prevent? 45 {:version 45} 75)) "version not a version string") ;; misshape (is (not (prevent? 45 {:latest {:version "0.46" :rollout 80}} 75)) "Wrong shape")) - (testing "Knows when to upgrade" (let [threshold 25 above 50 diff --git a/test/metabase/version/task/upgrade_checks_test.clj b/test/metabase/version/task/upgrade_checks_test.clj index 4065403a9c72..8a06b22ef651 100644 --- a/test/metabase/version/task/upgrade_checks_test.clj +++ b/test/metabase/version/task/upgrade_checks_test.clj @@ -18,7 +18,6 @@ :current-version (:tag config/mb-version-info)}} (constantly {:status 200 :body "{}"})} (is (= {} (@#'upgrade-checks/get-version-info)))))) - (testing "Empty values are omitted from the query params" (with-redefs [config/is-prod? true version.settings/site-uuid-for-version-info-fetching (constantly "") @@ -28,7 +27,6 @@ :query-params {}} (constantly {:status 200 :body "{}"})} (is (= {} (@#'upgrade-checks/get-version-info)))))) - (testing "No query parameters are sent outside of prod" (with-redefs [config/is-prod? false] (http-fake/with-fake-routes-in-isolation diff --git a/test/metabase/view_log/events/view_log_test.clj b/test/metabase/view_log/events/view_log_test.clj index 348e8a636ec7..d138e29f4453 100644 --- a/test/metabase/view_log/events/view_log_test.clj +++ b/test/metabase/view_log/events/view_log_test.clj @@ -97,7 +97,6 @@ (-> (t2/select-one-fn :last_viewed_at :model/Dashboard dashboard-id-1) t/offset-date-time (.withNano 0)))))) - (testing "if the existing last_viewed_at is greater than the updating values, do not override it" (mt/with-temp [:model/Dashboard {dashboard-id-2 :id} {:last_viewed_at now}] @@ -120,13 +119,11 @@ :has_access nil :context nil} (latest-view (u/id user) (u/id table))))) - (testing "If a user is bound, has_access is recorded in EE based on the user's current permissions" (mt/with-full-data-perms-for-all-users! (mt/with-current-user (u/id user) (events/publish-event! :event/table-read {:object table :user-id (u/id user)}) (is (true? (:has_access (latest-view (u/id user) (u/id table)))))) - ;; Bind the user again to flush the perms cache (mt/with-current-user (u/id user) (data-perms/set-table-permission! (perms-group/all-users) (mt/id :users) :perms/create-queries :no) @@ -550,7 +547,6 @@ (is (= "card" (:entity_type row))) (is (= "embedding-sdk-react" (:embedding_client row))) (is (= "public" (:embedding_route row))) - (is (= false (->bool (:is_preview row)))) (is (= "1.42.0" (:embedding_sdk_version row))) (is (= "public" (:auth_method row))) @@ -563,7 +559,6 @@ (let [row (latest-v-query-log (:id card))] (is (= "embedding-sdk-react" (:embedding_client row))) (is (= "public" (:embedding_route row))) - (is (= false (->bool (:is_preview row)))) (is (= "1.42.0" (:embedding_sdk_version row))) (is (= "public" (:auth_method row))) @@ -590,7 +585,6 @@ (is (= "card" (:entity_type row))) (is (= "embedding-sdk-react" (:embedding_client row))) (is (= "public" (:embedding_route row))) - (is (= false (->bool (:is_preview row)))) (is (= "1.42.0" (:embedding_sdk_version row))) (is (= "public" (:auth_method row))) @@ -603,7 +597,6 @@ (let [row (latest-v-query-log (:id card))] (is (= "embedding-sdk-react" (:embedding_client row))) (is (= "public" (:embedding_route row))) - (is (= false (->bool (:is_preview row)))) (is (= "1.42.0" (:embedding_sdk_version row))) (is (= "public" (:auth_method row))) diff --git a/test/metabase/warehouse_schema/models/field_test.clj b/test/metabase/warehouse_schema/models/field_test.clj index b6caea0a42d5..164f69316e40 100644 --- a/test/metabase/warehouse_schema/models/field_test.clj +++ b/test/metabase/warehouse_schema/models/field_test.clj @@ -63,7 +63,6 @@ (field/nested-field-names->field-id table-id ["top"]))) (is (= nested-field-id (field/nested-field-names->field-id table-id ["top" "nested"])))) - (testing "return nothing if field does not exist" (is (= nil (field/nested-field-names->field-id table-id ["top" "nested" "not-exists"])))))) diff --git a/test/metabase/warehouse_schema/models/field_values_test.clj b/test/metabase/warehouse_schema/models/field_values_test.clj index 2677e8eb34cd..4e8ab4236790 100644 --- a/test/metabase/warehouse_schema/models/field_values_test.clj +++ b/test/metabase/warehouse_schema/models/field_values_test.clj @@ -217,7 +217,6 @@ :model/FieldValues _ {:field_id field-id :type :full :values ["a" "b"] :human_readable_values ["A" "B"] :created_at before :updated_at before} :model/FieldValues _ {:field_id field-id :type :full :values ["c" "d"] :human_readable_values ["C" "D"] :created_at before :updated_at later} :model/FieldValues _ {:field_id field-id :type :full :values ["e" "f"] :human_readable_values ["E" "F"] :created_at after :updated_at after}] - (testing "When we have multiple FieldValues rows in the database, " (is (= 3 (count (t2/select :model/FieldValues :field_id field-id :type :full :hash_key nil)))) (testing "we always return the most recently updated row" @@ -257,13 +256,11 @@ field-values/get-or-create-full-field-values! :type))) (is (= 1 (t2/count :model/FieldValues :field_id (mt/id :categories :name) :type :full))) - (testing "if an Advanced FieldValues Exists, make sure we still returns the full FieldValues" (mt/with-temp [:model/FieldValues _ {:field_id (mt/id :categories :name) :type :sandbox :hash_key "random-hash"}] (is (= :full (:type (field-values/get-or-create-full-field-values! (t2/select-one :model/Field :id (mt/id :categories :name)))))))) - (testing "if an old FieldValues Exists, make sure we still return the full FieldValues and update last_used_at" (t2/query-one {:update :metabase_fieldvalues :where [:and @@ -319,11 +316,9 @@ :human_readable_values ["-2" "-1" "0" "a" "b" "c"]}] (is (= expected-original-values (find-values field-values-id))) - (testing "There should be no changes to human_readable_values when resync'd" (is (= expected-original-values (sync-and-find-values! db field-values-id)))) - (testing "Add new rows that will have new field values" (jdbc/insert-multi! {:connection conn} :foo [{:id 4 :category_id -2 :desc "foo"} {:id 5 :category_id -1 :desc "bar"} @@ -331,11 +326,9 @@ (testing "Sync to pickup the new field values and rebuild the human_readable_values" (is (= expected-updated-values (sync-and-find-values! db field-values-id))))) - (testing "Resyncing this (with the new field values) should result in the same human_readable_values" (is (= expected-updated-values (sync-and-find-values! db field-values-id)))) - (testing "Test that field values can be removed and the corresponding human_readable_values are removed as well" (jdbc/delete! {:connection conn} :foo ["id in (?,?,?)" 1 2 3]) (is (= {:values [-2 -1 0] :human_readable_values ["-2" "-1" "0"]} @@ -421,7 +414,6 @@ (is (thrown-with-msg? ExceptionInfo #"Can't update field_id, type, or hash_key for a FieldValues." (t2/update! :model/FieldValues id update-map))))) - (testing "The model hooks permits mention of the existing values" (doseq [[id update-map] [[full-id {:field_id (mt/id :venues :id)}] [sandbox-id {:type :sandbox}] @@ -476,7 +468,6 @@ (is (thrown-with-msg? ExceptionInfo #"Invalid query - :full FieldValues cannot have a hash_key" (t2/select :model/FieldValues :field_id field-id :type :full :hash_key "12345"))) - (t2/select :model/FieldValues :field_id field-id :type :sandbox) (t2/select :model/FieldValues :field_id field-id :type :sandbox :hash_key "12345") (is (thrown-with-msg? ExceptionInfo @@ -490,13 +481,11 @@ ;; Is there really a use-case for reading all these values? ;; Perhaps we should require a type/hash combo - we would need to be careful it doesn't break any existing queries. (is (= {:field_id 1} (#'field-values/add-mismatched-hash-filter {:field_id 1})))) - ;; There's an argument to be made that we should only query on these "identity" fields if the field-id is present, ;; but perhaps there are use cases that I haven't considered. (testing "Queries that fully specify the identity are not mangled" (is (= {:type :full, :hash_key nil} (#'field-values/add-mismatched-hash-filter {:type :full, :hash_key nil}))) (is (= {:type :sandbox, :hash_key "random-hash"} (#'field-values/add-mismatched-hash-filter {:type :sandbox, :hash_key "random-hash"})))) - (testing "Ambiguous queries are upgraded to ensure invalid rows are filtered" (is (= {:type :full, :hash_key nil} (#'field-values/add-mismatched-hash-filter {:type :full}))) (is (= {:type :sandbox, :hash_key [:not= nil]} (#'field-values/add-mismatched-hash-filter {:type :sandbox}))))) diff --git a/test/metabase/warehouse_schema/models/table_test.clj b/test/metabase/warehouse_schema/models/table_test.clj index 5cbd7658c6e4..cbcabc0503ef 100644 --- a/test/metabase/warehouse_schema/models/table_test.clj +++ b/test/metabase/warehouse_schema/models/table_test.clj @@ -108,7 +108,6 @@ :perms/manage-table-metadata :no :perms/manage-database :no}}} (data-perms.graph/data-permissions-graph :group-id all-users-group-id :db-id db-id)))) - ;; A new group starts with the same perms as All Users (is (partial= {group-id @@ -119,7 +118,6 @@ :perms/manage-table-metadata :no :perms/manage-database :no}}} (data-perms.graph/data-permissions-graph :group-id group-id :db-id db-id))) - (testing "A new table has appropriate defaults, when perms are already set granularly for the DB" (data-perms/set-table-permission! group-id table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! group-id table-id-1 :perms/download-results :no) @@ -167,7 +165,6 @@ ;; Manually activate Field values since they are not created during sync (#53387) (field-values/get-or-create-full-field-values! (t2/select-one :model/Field :id (mt/id :venues :price))) (field-values/get-or-create-full-field-values! (t2/select-one :model/Field :id (mt/id :venues :name))) - (is (=? {(mt/id :venues :price) (mt/malli=? [:sequential {:min 1} :any]) (mt/id :venues :name) (mt/malli=? [:sequential {:min 1} :any])} (-> (t2/select-one :model/Table (mt/id :venues)) @@ -504,7 +501,6 @@ clojure.lang.ExceptionInfo #"Cannot change data_source from metabase-transform" (t2/update! :model/Table table-id {:data_source nil})))))) - (testing "Cannot change data_source to metabase-transform" (mt/with-temp [:model/Table {table-id :id} {:data_source :ingested}] (testing "from another value" @@ -517,7 +513,20 @@ (is (= :ingested (t2/select-one-fn :data_source :model/Table :id table-id)))) (testing "can also change it to nil" (is (some? (t2/update! :model/Table table-id {:data_source nil}))) - (is (nil? (t2/select-one-fn :data_source :model/Table :id table-id))))))) + (is (nil? (t2/select-one-fn :data_source :model/Table :id table-id)))))) + (testing "data_source guard is relaxed for nil -> metabase-transform during deserialization (GDGT-2445)" + (testing "can set data_source to metabase-transform on an existing synced table" + (mt/with-temp [:model/Table {table-id :id} {:data_source nil}] + (binding [mi/*deserializing?* true] + (is (some? (t2/update! :model/Table table-id {:data_source :metabase-transform})))) + (is (= :metabase-transform (t2/select-one-fn :data_source :model/Table :id table-id))))) + (testing "reverse direction stays blocked even during deserialization" + (mt/with-temp [:model/Table {table-id :id} {:data_source :metabase-transform}] + (binding [mi/*deserializing?* true] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #"Cannot change data_source from metabase-transform" + (t2/update! :model/Table table-id {:data_source nil})))))))) (deftest is-published-and-collection-id-test (testing "is_published defaults to false" diff --git a/test/metabase/warehouse_schema_rest/api/field_test.clj b/test/metabase/warehouse_schema_rest/api/field_test.clj index 4c82435a21c8..f5810e9b7aa4 100644 --- a/test/metabase/warehouse_schema_rest/api/field_test.clj +++ b/test/metabase/warehouse_schema_rest/api/field_test.clj @@ -291,15 +291,12 @@ ;; now update the values via the API (is (= {:values [[1] [2] [3] [4]], :field_id (mt/id :venues :price), :has_more_values false} (mt/user-http-request :crowberto :get 200 (format "field/%d/values" (mt/id :venues :price))))))) - (testing "Should return nothing for a field whose `has_field_values` is not `list`" (is (= {:values [], :field_id (mt/id :venues :id), :has_more_values false} (mt/user-http-request :crowberto :get 200 (format "field/%d/values" (mt/id :venues :id)))))) - (testing "Sensitive fields do not have field values and should return empty" (is (= {:values [], :field_id (mt/id :users :password), :has_more_values false} (mt/user-http-request :crowberto :get 200 (format "field/%d/values" (mt/id :users :password)))))) - (testing "External remapping" (mt/with-column-remappings [venues.category_id categories.name] (mt/with-temp-vals-in-db :model/Field (mt/id :venues :category_id) {:has_field_values "list"} @@ -358,14 +355,11 @@ (is (= {:values [], :field_id true, :has_more_values false} (mt/boolean-ids-and-timestamps (mt/user-http-request :crowberto :get 200 (format "field/%d/values" field-id))))) - (is (= {:status "success"} (mt/user-http-request :crowberto :post 200 (format "field/%d/values" field-id) {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]]}))) - (is (= {:values [1 2 3 4], :human_readable_values ["$" "$$" "$$$" "$$$$"], :has_more_values false} (into {} (t2/select-one [:model/FieldValues :values :human_readable_values, :has_more_values] :field_id field-id)))) - (is (= {:values [[1 "$"] [2 "$$"] [3 "$$$"] [4 "$$$$"]], :field_id true, :has_more_values false} (mt/boolean-ids-and-timestamps (mt/user-http-request :crowberto :get 200 (format "field/%d/values" field-id))))))))) @@ -696,7 +690,6 @@ (testing "Checking update of the fk_target_field_id along with an FK change" (mt/with-temp [:model/Field {field-id-1 :id} {:name "Field Test 1"} :model/Field {field-id-2 :id} {:name "Field Test 2"}] - (testing "before change" (is (= {:name "Field Test 2" :display_name "Field Test 2" @@ -769,7 +762,6 @@ (testing "after API request" (is (= nil (dimension-for-field field-id)))))) - (testing "Change from supported type to supported type will leave the dimension" (mt/with-temp [:model/Field {field-id :id} {:name "Field Test" :base_type "type/Integer"}] diff --git a/test/metabase/warehouse_schema_rest/api/table_test.clj b/test/metabase/warehouse_schema_rest/api/table_test.clj index 9f7d6e2b0e29..046fd6c650f1 100644 --- a/test/metabase/warehouse_schema_rest/api/table_test.clj +++ b/test/metabase/warehouse_schema_rest/api/table_test.clj @@ -234,7 +234,6 @@ (juxt :schema :name) :object second))))))) - (testing "returns 404 for tables that don't exist" (mt/user-http-request :rasta :get 404 (format "table/%d/data" 133713371337))))))) @@ -265,7 +264,7 @@ :visibility_type "normal" :has_field_values "none" :database_required false - ;; Index sync is turned off across the application as it is not used ATM. + ;; Index sync is turned off across the application as it is not used ATM. #_#_:database_indexed true :database_is_auto_increment true :name_field {:base_type "type/Text", @@ -346,7 +345,7 @@ :base_type "type/BigInteger" :effective_type "type/BigInteger" :has_field_values "none" - ;; Index sync is turned off across the application as it is not used ATM. + ;; Index sync is turned off across the application as it is not used ATM. #_#_:database_indexed true :database_required false :database_is_auto_increment true @@ -472,32 +471,26 @@ (mt/with-temp [:model/Table table {}] (testing "Initially data_authority should be unconfigured" (is (= :unconfigured (t2/select-one-fn :data_authority :model/Table :id (u/the-id table))))) - (testing "Can save an unrelated change with this field redundantly included" (mt/user-http-request :crowberto :put 200 (format "table/%d" (u/the-id table)) {:active false, :data_authority "unconfigured"}) (is (= :unconfigured (t2/select-one-fn :data_authority :model/Table :id (u/the-id table))))) - (testing "Can set data_authority to authoritative" (mt/user-http-request :crowberto :put 200 (format "table/%d" (u/the-id table)) {:data_authority "authoritative"}) (is (= :authoritative (t2/select-one-fn :data_authority :model/Table :id (u/the-id table))))) - (testing "Can set data_authority between different values" (mt/user-http-request :crowberto :put 200 (format "table/%d" (u/the-id table)) {:data_authority "computed"}) (is (= :computed (t2/select-one-fn :data_authority :model/Table :id (u/the-id table))))) - (testing "Can set data_authority to ingested" (mt/user-http-request :crowberto :put 200 (format "table/%d" (u/the-id table)) {:data_authority "ingested"}) (is (= :ingested (t2/select-one-fn :data_authority :model/Table :id (u/the-id table))))) - (testing "Cannot un-configure again" (is (= "Cannot set data_authority back to unconfigured once it has been configured" (mt/user-http-request :crowberto :put 400 (format "table/%d" (u/the-id table)) {:data_authority "unconfigured"})))) - (testing "Cannot set data_authority to unknown via API" (is (= [:data_authority] (keys (:errors (mt/user-http-request :crowberto :put 400 (format "table/%d" (u/the-id table)) @@ -510,10 +503,8 @@ (t2/query-one {:update :metabase_table :set {:data_authority "federated"} :where [:= :id (:id table)]}) - (testing "Unexpected values are converted to :unknown" (is (= :unknown (t2/select-one-fn :data_authority [:model/Table :data_authority] :id (:id table))))) - (testing "API GET endpoint returns :unknown for tables with unknown data_authority" (let [api-response (mt/user-http-request :crowberto :get 200 (format "table/%d" (:id table)))] (is (= "unknown" (:data_authority api-response)))))))) @@ -551,7 +542,6 @@ (mt/user-http-request :crowberto :put 200 (format "table/%d" (:id table)) {:display_name (mt/random-name) :description "What a nice table!"})))] - (set-visibility! "hidden") (set-visibility! nil) ; <- should get synced (is (= 1 @@ -569,7 +559,6 @@ (set-name!) (is (= 2 @called))))))))) - (testing "Bulk updating visibility" (let [unhidden-ids (atom #{})] (mt/with-temp [:model/Table {id-1 :id} {} @@ -582,7 +571,6 @@ {:ids ids :visibility_type state})))] (set-many-vis! [id-1 id-2] nil) ;; unhides only 2 (is (= @unhidden-ids #{id-2})) - (set-many-vis! [id-1 id-2] "hidden") (is (= #{} @unhidden-ids)) ;; no syncing when they are hidden @@ -668,7 +656,7 @@ :effective_type "type/BigInteger" :has_field_values "none" :database_required false - ;; Index sync is turned off across the application as it is not used ATM. + ;; Index sync is turned off across the application as it is not used ATM. #_#_:database_indexed true :database_is_auto_increment true :name_field {:base_type "type/Text", @@ -1055,13 +1043,11 @@ (mt/user-http-request :rasta :post 403 url))) (testing "FieldValues should still exist" (is (t2/exists? :model/FieldValues :id (u/the-id field-values))))) - (testing "Admins should be able to successfuly delete them" (is (= {:status "success"} (mt/user-http-request :crowberto :post 200 url))) (testing "FieldValues should be gone" (is (not (t2/exists? :model/FieldValues :id (u/the-id field-values)))))))) - (testing "For tables that don't exist, we should return a 404." (is (= "Not found." (mt/user-http-request :crowberto :post 404 (format "table/%d/discard_values" Integer/MAX_VALUE))))))) @@ -1284,12 +1270,9 @@ {:display_name "Products"} {:display_name "Products2"}] (list-tables :term "P"))) - (mt/user-http-request :crowberto :put 200 (format "table/%d" products2-id) {:data_layer "final"}) - (is (=? [{:display_name "Products2"}] (list-tables :term "P" :data-layer "final"))) - (is (=? [{:display_name "People"} {:display_name "Products"}] (list-tables :term "P" :data-layer "internal"))))))) @@ -1302,13 +1285,11 @@ {:visibility_type "hidden"}) (is (= :hidden (t2/select-one-fn :data_layer :model/Table :id (u/the-id table)))) (is (= :hidden (t2/select-one-fn :visibility_type :model/Table :id (u/the-id table))))) - (testing "updating data_layer syncs to visibility_type" (mt/user-http-request :crowberto :put 200 (format "table/%d" (u/the-id table)) {:data_layer "internal"}) (is (= :internal (t2/select-one-fn :data_layer :model/Table :id (u/the-id table)))) (is (= nil (t2/select-one-fn :visibility_type :model/Table :id (u/the-id table))))) - (testing "cannot update both visibility_type and data_layer at once" (is (= "Cannot update both visibility_type and data_layer" (mt/user-http-request :crowberto :put 400 (format "table/%d" (u/the-id table)) @@ -1336,14 +1317,12 @@ (filter #(= (:db_id %) db-id)) (map :id) set)))) - (testing "both tables returned with orphan-only=false" (is (= #{table-1-id table-2-id} (->> (mt/user-http-request :crowberto :get 200 "table" :orphan-only false) (filter #(= (:db_id %) db-id)) (map :id) set)))) - (testing "only table-2 is returned with orphan-only=true" (is (= #{table-2-id} (->> (mt/user-http-request :crowberto :get 200 "table" :orphan-only true) @@ -1368,11 +1347,9 @@ (is (= (mt/id :continent :id) (get-fk-target)))) (is (= 1 (count (mt/user-http-request :rasta :get 200 (format "table/%d/fks" (mt/id :continent)))))) - ;; 2. drop the country table (jdbc/execute! db-spec "DROP TABLE country;") (sync/sync-database! db {:scan :schema}) - (is (= () (mt/user-http-request :rasta :get 200 (format "table/%d/fks" (mt/id :continent))))))))) ;;; ---------------------------------------- can-query and can-write filter tests ---------------------------------------- @@ -1393,7 +1370,6 @@ (data-perms/set-table-permission! pg table-1-id :perms/create-queries :query-builder) ;; Grant only view-data to table-2 (not queryable) (data-perms/set-table-permission! pg table-2-id :perms/view-data :unrestricted) - (let [response (->> (mt/user-http-request :rasta :get 200 "table" :can-query true) (filter #(= (:db_id %) db-id)))] (is (= 1 (count response))) diff --git a/test/metabase/warehouses/models/database_test.clj b/test/metabase/warehouses/models/database_test.clj index 3cf6ad6a7a3f..0aa444ae739d 100644 --- a/test/metabase/warehouses/models/database_test.clj +++ b/test/metabase/warehouses/models/database_test.clj @@ -60,7 +60,6 @@ :final-fire-time nil :data {"db-id" db-id}} (trigger-for-db db-id))) - (testing "When deleting a Database, sync tasks should get removed" (t2/delete! :model/Database :id db-id) (is (= nil @@ -93,21 +92,18 @@ (is (== 1 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "default"})) "healthy") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "user-input" :connection-type "default"})) "unhealthy user-input") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "exception" :connection-type "default"})) "unhealthy exception")))) - (testing "skip audit" (mt/with-prometheus-system! [_ system] (database/health-check-database! (assoc (mt/db) :is_audit true)) (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "default"})) "healthy") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "user-input" :connection-type "default"})) "unhealthy user-input") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "exception" :connection-type "default"})) "unhealthy exception"))) - (testing "skip sample" (mt/with-prometheus-system! [_ system] (database/health-check-database! (assoc (mt/db) :is_sample true)) (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "default"})) "healthy") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "user-input" :connection-type "default"})) "unhealthy user-input") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "exception" :connection-type "default"})) "unhealthy exception"))) - (testing "failures for timeout" (mt/with-prometheus-system! [_ system] (mt/with-temporary-setting-values [db-connection-timeout-ms -1] @@ -115,7 +111,6 @@ (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "default"})) "healthy") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "user-input" :connection-type "default"})) "unhealthy user-input") (is (== 1 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "exception" :connection-type "default"})) "unhealthy exception")))) - (testing "failures for bad connections" (when-let [bad-conn (tx/bad-connection-details driver/*driver*)] (mt/with-prometheus-system! [_ system] @@ -123,7 +118,6 @@ (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "default"})) "healthy") (is (or (== 1 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "user-input" :connection-type "default"})) (== 1 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy false :reason "exception" :connection-type "default"}))) "unhealthy user-input or exception")))) - (testing "failures for exception" (with-redefs [driver/can-connect? (fn [& _args] (throw (Exception. "boom")))] (mt/with-prometheus-system! [_ system] @@ -146,7 +140,6 @@ "default connection healthy") (is (== 1 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "write-data"})) "write-data connection healthy")))) - (testing "database without write_data_details only checks default connection" (mt/with-prometheus-system! [_ system] (mt/with-temporary-setting-values [db-connection-timeout-ms 30000] @@ -155,7 +148,6 @@ "default connection healthy") (is (== 0 (mt/metric-value system :metabase-database/status {:driver driver/*driver* :healthy true :connection-type "write-data"})) "no write-data connection checked")))) - (testing "write connection failure does not prevent default check" (let [call-count (atom 0)] (with-redefs [driver/can-connect? (fn [& _args] @@ -267,7 +259,6 @@ "engine" "bigquery-cloud-sdk" "settings" {"database-enable-actions" true}} (encode-decode bq-db))))) - (testing "details are obfuscated for admin users" (request/with-current-user (mt/user->id :crowberto) @@ -431,24 +422,20 @@ (is (= expected (t2/select-one-fn (comp json/decode+kw :details) (t2/table-name :model/Database) db-id))) (is (=? {:value (u/string-to-bytes "secret") :source :uploaded :version 1} (secret/latest-for-id secret-id))) - (t2/update! :model/Database db-id {:details {:keystore-path "secret-path"}}) (is (= expected (t2/select-one-fn (comp json/decode+kw :details) (t2/table-name :model/Database) db-id))) (is (=? {:value (u/string-to-bytes "secret-path") :source :file-path :version 2} (secret/latest-for-id secret-id))) - (t2/update! :model/Database db-id {:details {:keystore-path "ignore-path" :keystore-value "prefer-value"}}) (is (= expected (t2/select-one-fn (comp json/decode+kw :details) (t2/table-name :model/Database) db-id))) (is (=? {:value (u/string-to-bytes "prefer-value") :source :uploaded :version 3} (secret/latest-for-id secret-id))) - (t2/update! :model/Database db-id {:details {:keystore-options "local" :keystore-path "prefer-path" :keystore-value "ignore-value"}}) (is (= expected (t2/select-one-fn (comp json/decode+kw :details) (t2/table-name :model/Database) db-id))) (is (=? {:value (u/string-to-bytes "prefer-path") :source :file-path :version 4} (secret/latest-for-id secret-id))) - (t2/update! :model/Database db-id {:details {:keystore-value nil}}) (is (= {} (t2/select-one-fn (comp json/decode+kw :details) (t2/table-name :model/Database) db-id))) (is (=? nil @@ -471,7 +458,6 @@ :created_at (t/instant) :updated_at (t/instant) :details (json/encode original-details)}] - (testing "Initially setting secret value" (is (=? (=?/malli expected-path-response) (:details (mt/user-http-request :crowberto :put 200 (format "database/%d" db-id) @@ -481,11 +467,9 @@ (is (=? (=?/malli host-and-keystore-id) (json/decode (:details (t2/select-one db-table db-id)) keyword)) "Database value") - (is (=? (=?/malli expected-path-response) (:details (mt/user-http-request :crowberto :get 200 (format "database/%d" db-id)))) "API request")) - (testing "Change secret value from local path to uploaded" (is (=? (=?/malli (conj host-and-keystore-id ;; The secret gets passed back on the put for the ui @@ -495,11 +479,9 @@ {:details (assoc original-details :keystore-value secret-key :keystore-options "uploaded")})))) - (is (=? (=?/malli host-and-keystore-id) (json/decode (:details (t2/select-one db-table db-id)) keyword)) "Database value") - (is (=? (=?/malli (conj host-and-keystore-id [:keystore-value [:enum secret/protected-password]] [:keystore-options [:enum "uploaded"]])) @@ -975,7 +957,6 @@ (data-perms/set-table-permission! pg table1-id :perms/create-queries :query-builder) (data-perms/set-table-permission! pg table2-id :perms/view-data :unrestricted) (data-perms/set-table-permission! pg table2-id :perms/create-queries :query-builder) - (is (contains? (fetch-visible-db-ids [db-id] {:user-id (mt/user->id :rasta) :is-superuser? false} default-permission-mapping diff --git a/test/metabase/warehouses/provider_detection_test.clj b/test/metabase/warehouses/provider_detection_test.clj index 0e3e4628fb1b..7a9ff3c037cf 100644 --- a/test/metabase/warehouses/provider_detection_test.clj +++ b/test/metabase/warehouses/provider_detection_test.clj @@ -22,7 +22,6 @@ providers: (testing "database with unsupported engine returns nil" (let [database {:details {:host "czrs8kj4isg7.us-east-1.rds.amazonaws.com"} :engine :mysql}] (is (nil? (provider-detection/detect-provider-from-database database))))) - (testing "database without host returns nil" (let [database {:details {} :engine :postgres}] (is (nil? (provider-detection/detect-provider-from-database database)))))) diff --git a/test/metabase/warehouses/settings_test.clj b/test/metabase/warehouses/settings_test.clj index 4a4826ca6fb9..bc857b3a4e72 100644 --- a/test/metabase/warehouses/settings_test.clj +++ b/test/metabase/warehouses/settings_test.clj @@ -14,7 +14,6 @@ (testing "Setting returns ips given comma delimited ips." (is (= ["1.2.3.4" "5.6.7.8"] (warehouses.settings/cloud-gateway-ips))))) - (testing "Setting returns nil in self-hosted environments" (with-redefs [premium-features/is-hosted? (constantly false)] (is (= nil (warehouses.settings/cloud-gateway-ips))))))) diff --git a/test/metabase/warehouses_rest/api_test.clj b/test/metabase/warehouses_rest/api_test.clj index e4b3ef46c9dc..688141f7f7f3 100644 --- a/test/metabase/warehouses_rest/api_test.clj +++ b/test/metabase/warehouses_rest/api_test.clj @@ -216,7 +216,7 @@ :model/Table {table-id-1 :id} {:db_id db-id} :model/Table {table-id-2 :id} {:db_id db-id}] (mt/with-no-data-perms-for-all-users! - ;; Query permissions for a single table is enough to fetch the DB + ;; Query permissions for a single table is enough to fetch the DB (data-perms/set-table-permission! group table-id-1 :perms/view-data :legacy-no-self-service) (data-perms/set-table-permission! group table-id-1 :perms/create-queries :no) (data-perms/set-table-permission! group table-id-2 :perms/view-data :unrestricted) @@ -509,7 +509,6 @@ (mt/with-temp [:model/Database db] (mt/user-http-request :crowberto :delete 204 (format "database/%d" (:id db))) (is (false? (t2/exists? :model/Database :id (u/the-id db)))))) - (testing "Check that a non-superuser cannot delete a Database" (mt/with-temp [:model/Database db] (mt/user-http-request :rasta :delete 403 (format "database/%d" (:id db))))))) @@ -676,7 +675,6 @@ (:settings (mt/user-http-request :crowberto :put 200 (format "database/%s" db-id) {:settings {:database-enable-actions true}})))))) - (testing "should not validate settings where the value hasn't changed" ;; Same setup, but we set the same value as before - should skip validation (mt/with-temp [:model/Database {db-id :id} {:engine :h2 @@ -685,7 +683,6 @@ (:settings (mt/user-http-request :crowberto :put 200 (format "database/%s" db-id) {:settings {:api-test-disabled-for-database true}})))))) - (testing "should still validate settings that are actually being changed to a new value" ;; If we try to change api-test-disabled-for-database to a different value, it should fail validation (mt/with-temp [:model/Database {db-id :id} {:engine :h2 @@ -694,7 +691,6 @@ (:message (mt/user-http-request :crowberto :put 400 (format "database/%s" db-id) {:settings {:api-test-disabled-for-database true}})))))) - (testing "should not validate settings being reset to nil (default)" ;; Resetting a setting to nil should always be allowed, even if the setting would fail validation (mt/with-temp [:model/Database {db-id :id} {:engine :h2 @@ -703,7 +699,6 @@ (:settings (mt/user-http-request :crowberto :put 200 (format "database/%s" db-id) {:settings {:api-test-disabled-for-database nil}})))))) - (testing "should not validate settings being reset to default value (literally)" ;; Resetting a setting to default should always be allowed, even if the setting would fail validation (mt/with-temp [:model/Database {db-id :id} {:engine :h2 @@ -1412,7 +1407,6 @@ (:metadata_sync_schedule db))) (is (not= (u.cron/schedule-map->cron-string schedule-map-for-last-friday-at-11pm) (:cache_field_values_schedule db))))) - (testing "update db setting with a custom trigger should reschedule scan field values" (mt/user-http-request :crowberto :put 200 (format "/database/%d" (:id db)) {:details {:let-user-control-scheduling true} @@ -1427,7 +1421,6 @@ (:metadata_sync_schedule db))) (is (= (u.cron/schedule-map->cron-string schedule-map-for-last-friday-at-11pm) (:cache_field_values_schedule db))))) - (testing "update db setting to never scan should remove scan field values trigger" (mt/user-http-request :crowberto :put 200 (format "/database/%d" (:id db)) {:details {:let-user-control-scheduling true} @@ -1441,7 +1434,6 @@ (is (= (u.cron/schedule-map->cron-string schedule-map-for-weekly) (:metadata_sync_schedule db))) (is (nil? (:cache_field_values_schedule db))))) - (testing "turn back to default settings should recreate all tasks with randomized schedule" (mt/user-http-request :crowberto :put 200 (format "/database/%d" (:id db)) {:details {:let-user-control-scheduling false} @@ -1452,7 +1444,7 @@ (is (= (task.sync-databases-test/all-db-sync-triggers-name db) (task.sync-databases-test/query-all-db-sync-triggers-name db))) (let [db (t2/select-one :model/Database (:id db))] - ;; make sure the new schedule is randomized, not from the payload + ;; make sure the new schedule is randomized, not from the payload (is (not= (-> schedule-map-for-weekly u.cron/schedule-map->cron-string) (:metadata_sync_schedule db))) (is (not= (-> schedule-map-for-last-friday-at-11pm u.cron/schedule-map->cron-string) @@ -1627,16 +1619,13 @@ :model/Field field-2 {:table_id (u/the-id table-2)} :model/FieldValues values-1 {:field_id (u/the-id field-1), :values [1 2 3 4]} :model/FieldValues values-2 {:field_id (u/the-id field-2), :values [1 2 3 4]}] - (snowplow-test/with-fake-snowplow-collector (is (= {:status "ok"} (mt/user-http-request :crowberto :post 200 (format "database/%d/discard_values" (u/the-id db))))) - (testing "triggers snowplow event" (is (=? {"event" "database_discard_field_values", "target_id" (u/the-id db)} (:data (last (snowplow-test/pop-event-data-and-user-id!))))))) - (testing "values-1 still exists?" (is (= false (t2/exists? :model/FieldValues :id (u/the-id values-1))))) @@ -1721,27 +1710,21 @@ (#'warehouses.util/test-connection-details "postgres" {:ssl true}) (is (= 1 @call-count)) (is (= [true] @ssl-values))) - (reset! call-count 0) (reset! ssl-values []) - (testing "with SSL disabled, try twice (once with, once without SSL)" (#'warehouses.util/test-connection-details "postgres" {:ssl false}) (is (= 2 @call-count)) (is (= [true false] @ssl-values))) - (reset! call-count 0) (reset! ssl-values []) - (testing "with SSL unspecified, try twice (once with, once without SSL)" (#'warehouses.util/test-connection-details "postgres" {}) (is (= 2 @call-count)) (is (= [true nil] @ssl-values))) - (reset! call-count 0) (reset! ssl-values []) (reset! valid? true) - (testing "with SSL disabled, but working try once (since SSL work we don't try without SSL)" (is (= {:ssl true} (#'warehouses.util/test-connection-details "postgres" {:ssl false}))) @@ -1762,11 +1745,9 @@ :model/Table _ {:db_id db-id :schema "schema1"}] (is (= ["schema1" "schema2" "schema3"] (mt/user-http-request :rasta :get 200 (format "database/%d/schemas" db-id)))))) - (testing "Looking for a database that doesn't exist should return a 404" (is (= "Not found." (mt/user-http-request :crowberto :get 404 (format "database/%s/schemas" Integer/MAX_VALUE))))) - (testing "should work for the saved questions 'virtual' database" (mt/with-temp [:model/Collection coll {:name "My Collection"} :model/Card card-1 (assoc (card-with-native-query "Card 1") :collection_id (:id coll)) @@ -1823,7 +1804,7 @@ ;; table is not visible. Any non-nil value of `visibility_type` means Table shouldn't be visible :model/Table _ {:db_id db-id :schema "schema_2" :name "table_2a" :visibility_type "hidden"} :model/Table _ {:db_id db-id :schema "schema_2" :name "table_2b" :visibility_type "cruft"} - ;; table is not active + ;; table is not active :model/Table _ {:db_id db-id :schema "schema_3" :name "table_3" :active false}] (testing "GET /api/database/:id/schemas should not return schemas with no VISIBLE TABLES" (is (= ["schema_1a" "schema_1b" "schema_1c"] @@ -1841,7 +1822,6 @@ (is (= ["schema1"] (mt/with-full-data-perms-for-all-users! (mt/user-http-request :rasta :get 200 (format "database/%d/schemas" db-id)))))) - (testing "...or just table read perms..." (mt/with-no-data-perms-for-all-users! (data-perms/set-database-permission! (perms-group/all-users) db-id :perms/view-data :unrestricted) @@ -1849,12 +1829,10 @@ (data-perms/set-table-permission! (perms-group/all-users) (u/the-id t2) :perms/create-queries :query-builder) (is (= ["schema1"] (mt/user-http-request :rasta :get 200 (format "database/%d/schemas" db-id)))))) - (testing "should return a 403 for a user that doesn't have read permissions for the database" (mt/with-no-data-perms-for-all-users! (is (= "You don't have permissions to do that." (mt/user-http-request :rasta :get 403 (format "database/%s/schemas" db-id)))))) - (testing "returns empty list when user has no create-queries perms for any schema" (mt/with-full-data-perms-for-all-users! (data-perms/set-database-permission! (perms-group/all-users) db-id :perms/view-data :unrestricted) @@ -1863,7 +1841,6 @@ ;; User can access the endpoint but sees no schemas since they have no query perms (is (= [] (mt/user-http-request :rasta :get 200 (format "database/%s/schemas" db-id))))))) - (testing "should exclude schemas for which the user has no perms" (mt/with-temp [:model/Database {database-id :id} {} :model/Table {t1-id :id} {:db_id database-id :schema "schema-with-perms"} @@ -1949,7 +1926,6 @@ :type "question"}] (mt/user-http-request :lucky :get 200 (format "database/%d/schema/%s" lib.schema.id/saved-questions-virtual-database-id "My Collection"))))) - (testing "Should be able to get saved questions in the root collection" (let [response (mt/user-http-request :lucky :get 200 (format "database/%d/schema/%s" lib.schema.id/saved-questions-virtual-database-id @@ -1974,7 +1950,6 @@ :schema (schema.table/root-collection-schema-name) :description nil :type "question"})))) - (testing "Should throw 404 if the schema/Collection doesn't exist" (is (= "Not found." (mt/user-http-request :lucky :get 404 @@ -1997,7 +1972,7 @@ :model/Card card-2 (assoc (card-with-native-query "Card 2") :type :model) :model/Card _card-3 (assoc (card-with-native-query "error") - ;; regular saved question should not be in the results + ;; regular saved question should not be in the results :type :question)] ;; run the cards to populate their result_metadata columns (doseq [card [card-1 card-2]] @@ -2020,7 +1995,6 @@ :schema "My Collection"}] (mt/user-http-request :lucky :get 200 (format "database/%d/datasets/%s" lib.schema.id/saved-questions-virtual-database-id "My Collection"))))) - (testing "Should be able to get datasets in the root collection" (let [response (mt/user-http-request :lucky :get 200 (format "database/%d/datasets/%s" lib.schema.id/saved-questions-virtual-database-id @@ -2044,7 +2018,6 @@ :schema (schema.table/root-collection-schema-name) :description nil :type "model"})))) - (testing "Should throw 404 if the schema/Collection doesn't exist" (is (= "Not found." (mt/user-http-request :lucky :get 404 @@ -2123,14 +2096,12 @@ (mt/with-full-data-perms-for-all-users! (is (= ["t1" "t3"] (map :name (mt/user-http-request :rasta :get 200 (format "database/%d/schema/%s" db-id "schema1"))))))) - (testing "if we have query perms for all tables in the schema" (mt/with-no-data-perms-for-all-users! (data-perms/set-table-permission! (perms-group/all-users) (u/the-id t1) :perms/create-queries :query-builder) (data-perms/set-table-permission! (perms-group/all-users) (u/the-id t3) :perms/create-queries :query-builder) (is (= ["t1" "t3"] (map :name (mt/user-http-request :rasta :get 200 (format "database/%d/schema/%s" db-id "schema1"))))))) - (testing "if we have query perms for one table in the schema, and legacy-no-self-service data perms for another" (mt/with-no-data-perms-for-all-users! (data-perms/set-table-permission! (perms-group/all-users) (u/the-id t1) :perms/view-data :legacy-no-self-service) @@ -2574,7 +2545,6 @@ {:key "custom/three" :type "error" :message "Never"}]}} - (select-keys settings [:unaggregated-query-row-limit :api-test-missing-premium-feature :api-test-missing-driver-feature @@ -2603,7 +2573,6 @@ (data-perms/set-database-permission! pg db-1-id :perms/create-queries :query-builder) ;; Grant only view-data to db-2 (not queryable) (data-perms/set-database-permission! pg db-2-id :perms/view-data :unrestricted) - (let [response (->> (mt/user-http-request :rasta :get 200 "database" :can-query true) :data (filter #(#{db-1-id db-2-id} (:id %))))] @@ -2620,7 +2589,6 @@ (data-perms/set-database-permission! (perms-group/all-users) db-id :perms/view-data :unrestricted) ;; Grant create-queries only to t1 (queryable) (data-perms/set-table-permission! (perms-group/all-users) t1 :perms/create-queries :query-builder) - (let [response (mt/user-http-request :rasta :get 200 (format "database/%d/schemas" db-id) :can-query true)] (is (= ["queryable_schema"] response))))))) @@ -2634,7 +2602,6 @@ (data-perms/set-database-permission! (perms-group/all-users) db-id :perms/view-data :unrestricted) ;; Grant create-queries only to t1 (queryable) (data-perms/set-table-permission! (perms-group/all-users) t1 :perms/create-queries :query-builder) - (let [response (mt/user-http-request :rasta :get 200 (format "database/%d/schema/%s" db-id "test_schema") :can-query true)] (is (= 1 (count response))) (is (= "queryable_table" (-> response first :name)))))))) diff --git a/test/metabase/xrays/api/automagic_dashboards_test.clj b/test/metabase/xrays/api/automagic_dashboards_test.clj index 119eb7849db9..d222ed4286e6 100644 --- a/test/metabase/xrays/api/automagic_dashboards_test.clj +++ b/test/metabase/xrays/api/automagic_dashboards_test.clj @@ -77,7 +77,6 @@ (deftest table-xray-test (testing "GET /api/automagic-dashboards/table/:id" (is (some? (api-call! "table/%s" [(mt/id :venues)])))) - (testing "GET /api/automagic-dashboards/table/:id/rule/example/indepth" (is (some? (api-call! "table/%s/rule/example/indepth" [(mt/id :venues)]))))) @@ -143,13 +142,11 @@ [(fn [collection-id card-id] (testing "GET /api/automagic-dashboards/question/:id" (is (some? (api-call! "question/%s" [card-id] #(revoke-collection-permissions! collection-id)))))) - (fn [collection-id card-id] (testing "GET /api/automagic-dashboards/question/:id/cell/:cell-query" (is (some? (api-call! "question/%s/cell/%s" [card-id cell-query] #(revoke-collection-permissions! collection-id)))))) - (fn [collection-id card-id] (testing "GET /api/automagic-dashboards/question/:id/cell/:cell-query/rule/example/indepth" (is (some? (api-call! "question/%s/cell/%s/rule/example/indepth" @@ -172,13 +169,11 @@ [(fn [collection-id card-id] (testing "GET /api/automagic-dashboards/model/:id" (is (some? (api-call! "model/%s" [card-id] #(revoke-collection-permissions! collection-id)))))) - (fn [collection-id card-id] (testing "GET /api/automagic-dashboards/model/:id/cell/:cell-query" (is (some? (api-call! "model/%s/cell/%s" [card-id cell-query] #(revoke-collection-permissions! collection-id)))))) - (fn [collection-id card-id] (testing "GET /api/automagic-dashboards/model/:id/cell/:cell-query/rule/example/indepth" (is (some? (api-call! "model/%s/cell/%s/rule/example/indepth" @@ -218,10 +213,8 @@ [:> [:field (mt/id :venues :price) nil] 5])] (testing "GET /api/automagic-dashboards/adhoc/:query" (is (some? (api-call! "adhoc/%s" [query])))) - (testing "GET /api/automagic-dashboards/adhoc/:query/cell/:cell-query" (is (some? (api-call! "adhoc/%s/cell/%s" [query cell-query])))) - (testing "GET /api/automagic-dashboards/adhoc/:query/cell/:cell-query/rule/example/indepth" (is (some? (api-call! "adhoc/%s/cell/%s/rule/example/indepth" [query cell-query])))))) diff --git a/test/metabase/xrays/automagic_dashboards/core_test.clj b/test/metabase/xrays/automagic_dashboards/core_test.clj index d7aa1c325db1..e4ad9f4b38b0 100644 --- a/test/metabase/xrays/automagic_dashboards/core_test.clj +++ b/test/metabase/xrays/automagic_dashboards/core_test.clj @@ -1318,7 +1318,7 @@ {"Lat" {:field_type [:type/Latitude], :score 90}} {"Lat" {:field_type [:entity/GenericTable :type/Latitude], :score 100}} {"Lat" {:field_type [:entity/UserTable :type/Latitude], :score 100}}] - ;; These will be matched in our tests since this is a generic table entity. + ;; These will be matched in our tests since this is a generic table entity. bindable-dimensions (remove #(-> % vals first :field_type first #{:entity/UserTable}) dimensions) diff --git a/test/metabase/xrays/automagic_dashboards/dashboard_templates_test.clj b/test/metabase/xrays/automagic_dashboards/dashboard_templates_test.clj index d6717363227b..3a2f4bd2a46e 100644 --- a/test/metabase/xrays/automagic_dashboards/dashboard_templates_test.clj +++ b/test/metabase/xrays/automagic_dashboards/dashboard_templates_test.clj @@ -16,7 +16,6 @@ "fields"]] (testing s (is (every? some? (dashboard-templates/get-dashboard-templates [s])))))) - (is (some? (dashboard-templates/get-dashboard-templates ["table" "GenericTable" "ByCountry"])))) (deftest ^:parallel dimension-form?-test diff --git a/test/metabase/xrays/automagic_dashboards/populate_test.clj b/test/metabase/xrays/automagic_dashboards/populate_test.clj index b7aa09ea0688..9fff4b92d9b0 100644 --- a/test/metabase/xrays/automagic_dashboards/populate_test.clj +++ b/test/metabase/xrays/automagic_dashboards/populate_test.clj @@ -25,14 +25,12 @@ ["Geographical" [{:group "Geographical", :k 1}]] ["ByTime" [{:group "ByTime", :k 1}]]] results))) - (testing "If there's no key order we just get a seq of the grouped map" (let [results (populate/ordered-group-by-seq :group nil [{:k 1} {:k 2} {:k 3}])] (is (=? [[nil [{:k 1} {:k 2} {:k 3}]]] results)))) - (testing "Returns remaining keys at end if they aren't asked for" (let [groups {"Overview" {:title "Summary", :score 90}, "Singletons" {:title "These are the same for all [[this.short-name]]", diff --git a/test/metabase/xrays/domain_entities/converters_test.cljs b/test/metabase/xrays/domain_entities/converters_test.cljs index c10e7ccbb369..21aaaef87000 100644 --- a/test/metabase/xrays/domain_entities/converters_test.cljs +++ b/test/metabase/xrays/domain_entities/converters_test.cljs @@ -52,7 +52,6 @@ (testing "incoming maps" (testing "become CLJS maps" (is (map? ((converters/incoming [:map]) #js {})))) - (testing "have both declared and undeclared keys normalized as :kebab-case-keywords" (is (= {:declared-camel "yes" :declared-snake "also" @@ -66,7 +65,6 @@ "undeclaredCamel" 7 "undeclared_snake" 8 "undeclared-kebab" 9})))) - (testing "work like maps for their declared keys" (let [converted (->half-declared #js {"declaredCamel" "yes" "declared_snake" "also" @@ -79,7 +77,6 @@ (is (= "also" (converted :declared-snake))) (is (= "finally" (converted :declared-kebab))) (is (= :not-found (converted :does-not-exist :not-found))) - (is (= #{:declared-camel :declared-snake :declared-kebab} (set (keys converted)))) (is (= #{"yes" "also" "finally"} @@ -90,7 +87,6 @@ :declared-kebab "finally"}] (is (= native converted)) (is (= converted native)))))) - (testing "outgoing maps" (let [input #js {"declaredCamel" "yes" "declared_snake" "also" @@ -99,7 +95,6 @@ "undeclared_snake" 8 "undeclared-kebab" 9} obj (->half-declared input)] - (testing "are converted per the schema by :js/prop; defaulting to snake_case" (let [adjusted (assoc obj :declared-camel "no")] (is (not (identical? obj adjusted))) @@ -127,7 +122,6 @@ converted ((converters/incoming Grandparent) input)] (is (= exp-clj converted)) (is (test.js/= input ((converters/outgoing Grandparent) converted))))) - (testing "nesting kitchen sink" (let [schema [:map [:foo-bar {:js/prop "fooBar"} @@ -222,7 +216,6 @@ ((converters/outgoing :uuid) uuid))) (is (= uuid ((converters/incoming :uuid) (str uuid)))))) - (testing "UUIDs nested in maps work too" (let [uuid (random-uuid) schema [:map [:id :uuid]]] @@ -230,7 +223,6 @@ ((converters/outgoing schema) {:id uuid}))) (is (= {:id uuid} ((converters/incoming schema) #js {:id (str uuid)}))))) - (testing "UUIDs nested in maps inside a map-of work too" (let [uuid (random-uuid) schema [:map-of :string [:map [:id :uuid]]]] diff --git a/test/metabase/xrays/transforms/core_test.clj b/test/metabase/xrays/transforms/core_test.clj index 0566104ba193..36c36b5fc013 100644 --- a/test/metabase/xrays/transforms/core_test.clj +++ b/test/metabase/xrays/transforms/core_test.clj @@ -39,7 +39,6 @@ "D3" [:field 5 nil]}] (is (= (update-in @test-bindings ["Venues" :dimensions] merge new-bindings) (#'tf/add-bindings @test-bindings "Venues" new-bindings))))) - (testing "Gracefully handle nil" (is (= @test-bindings (#'tf/add-bindings @test-bindings "Venues" nil))))) @@ -57,7 +56,6 @@ (testing "for a Table" (is (= (mt/id :venues) (#'tf/->source-table-reference (t2/select-one :model/Table :id (mt/id :venues)))))) - (testing "for a Card" (mt/with-temp [:model/Card {card-id :id}] (is (= (str "card__" card-id) @@ -116,7 +114,6 @@ {:base_type :type/Number, :name "MinPrice"}]}) :dimensions {"D1" [:field 1 nil]}}} (first @tf.specs/*transform-specs*)))) - (testing "... and do we throw if we didn't get what we expected?" (is (thrown? java.lang.AssertionError diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_average_order.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_average_order.yaml index 7b4db6e3509e..443c44cfb759 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_average_order.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_average_order.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_cumulative_orders.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_cumulative_orders.yaml index 52536de48108..48125b5bd8a4 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_cumulative_orders.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_cumulative_orders.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_orders.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_orders.yaml index e2732846dad8..8b1298401a50 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_orders.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_orders.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_revenue.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_revenue.yaml index 06d8dc52dc22..b287748684ff 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_revenue.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/monthly_revenue.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/native_orders_query.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/native_orders_query.yaml index 93ad29da9cd1..8832ce35b482 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/native_orders_query.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/native_orders_query.yaml @@ -4,8 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlDaShBoArDs0ExAmPlx -query_type: native -database_id: Sample Database parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/total_revenue.yaml b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/total_revenue.yaml index 6bda5c691ba9..1d898ed1b51c 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/total_revenue.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/series_and_parameter_mappings/total_revenue.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: scalar collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/dashboards/tabbed_dashboard/products_table.yaml b/test_resources/serialization_baseline/collections/main/dashboards/tabbed_dashboard/products_table.yaml index d2d591e62c8f..7f79f7fe0529 100644 --- a/test_resources/serialization_baseline/collections/main/dashboards/tabbed_dashboard/products_table.yaml +++ b/test_resources/serialization_baseline/collections/main/dashboards/tabbed_dashboard/products_table.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlDaShBoArDs0ExAmPlx -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/documents/product_analysis_report/category_breakdown.yaml b/test_resources/serialization_baseline/collections/main/documents/product_analysis_report/category_breakdown.yaml index 5ef03dcfc894..dfbabc125f17 100644 --- a/test_resources/serialization_baseline/collections/main/documents/product_analysis_report/category_breakdown.yaml +++ b/test_resources/serialization_baseline/collections/main/documents/product_analysis_report/category_breakdown.yaml @@ -4,12 +4,6 @@ created_at: '2025-01-15T10:00:00Z' creator_id: internal@metabase.com display: bar collection_id: cOlDoCuMeNtS0ExAmPlx2 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/product_overview.yaml b/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/product_overview.yaml index 89b9a37382c8..cff8110645d3 100644 --- a/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/product_overview.yaml +++ b/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/product_overview.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: M-Q4pcV0qkiyJ0kiSWECl -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/top_products_by_revenue.yaml b/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/top_products_by_revenue.yaml index 7f0ea13d0109..cd51609c6790 100644 --- a/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/top_products_by_revenue.yaml +++ b/test_resources/serialization_baseline/collections/main/minimal/marketing_analytics/top_products_by_revenue.yaml @@ -4,8 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: bar collection_id: M-Q4pcV0qkiyJ0kiSWECl -query_type: native -database_id: Sample Database parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/arithmetic_expressions.yaml b/test_resources/serialization_baseline/collections/main/queries/arithmetic_expressions.yaml index 7d3aab9d41a0..f19107ff4e42 100644 --- a/test_resources/serialization_baseline/collections/main/queries/arithmetic_expressions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/arithmetic_expressions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/basic_aggregations.yaml b/test_resources/serialization_baseline/collections/main/queries/basic_aggregations.yaml index 67f979e095be..c8c406500e86 100644 --- a/test_resources/serialization_baseline/collections/main/queries/basic_aggregations.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/basic_aggregations.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: @@ -67,8 +61,8 @@ dataset_query: - ORDERS - PRODUCT_ID - - sum - - display-name: Total Revenue - name: total_revenue + - name: total_revenue + display-name: Total Revenue - - field - base-type: type/Float - - Sample Database diff --git a/test_resources/serialization_baseline/collections/main/queries/comparison_filters.yaml b/test_resources/serialization_baseline/collections/main/queries/comparison_filters.yaml index b396322596d8..80ffbeb982db 100644 --- a/test_resources/serialization_baseline/collections/main/queries/comparison_filters.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/comparison_filters.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/conditional_aggregations.yaml b/test_resources/serialization_baseline/collections/main/queries/conditional_aggregations.yaml index 2af59af89917..83df9c86fcd5 100644 --- a/test_resources/serialization_baseline/collections/main/queries/conditional_aggregations.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/conditional_aggregations.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/conditional_and_type_conversion.yaml b/test_resources/serialization_baseline/collections/main/queries/conditional_and_type_conversion.yaml index b2744ccc3efb..322710543f64 100644 --- a/test_resources/serialization_baseline/collections/main/queries/conditional_and_type_conversion.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/conditional_and_type_conversion.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: @@ -17,8 +11,8 @@ dataset_query: stages: - expressions: - - case - - default: Budget - lib/expression-name: Price Tier + - lib/expression-name: Price Tier + default: Budget - - - - '>' - {} - - field @@ -40,8 +34,8 @@ dataset_query: - 30 - Standard - - if - - default: false - lib/expression-name: Is Premium + - lib/expression-name: Is Premium + default: false - - - - '>' - {} - - field diff --git a/test_resources/serialization_baseline/collections/main/queries/cumulative_and_statistical_aggregations.yaml b/test_resources/serialization_baseline/collections/main/queries/cumulative_and_statistical_aggregations.yaml index 848190a75cf4..5e30f74e667f 100644 --- a/test_resources/serialization_baseline/collections/main/queries/cumulative_and_statistical_aggregations.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/cumulative_and_statistical_aggregations.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/fields_and_expressions.yaml b/test_resources/serialization_baseline/collections/main/queries/fields_and_expressions.yaml index 67a2c256ca65..a885491d4d29 100644 --- a/test_resources/serialization_baseline/collections/main/queries/fields_and_expressions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/fields_and_expressions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/geographic_filter__inside_.yaml b/test_resources/serialization_baseline/collections/main/queries/geographic_filter__inside_.yaml index b1c1759957bc..b150f63494d8 100644 --- a/test_resources/serialization_baseline/collections/main/queries/geographic_filter__inside_.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/geographic_filter__inside_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: map collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PEOPLE parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/joins___strategies_and_compound_conditions.yaml b/test_resources/serialization_baseline/collections/main/queries/joins___strategies_and_compound_conditions.yaml index 6539e4fed849..a01453848ea5 100644 --- a/test_resources/serialization_baseline/collections/main/queries/joins___strategies_and_compound_conditions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/joins___strategies_and_compound_conditions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/math_functions.yaml b/test_resources/serialization_baseline/collections/main/queries/math_functions.yaml index 7ab92edad289..dfc52013a038 100644 --- a/test_resources/serialization_baseline/collections/main/queries/math_functions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/math_functions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/metric__measure__and_segment_references.yaml b/test_resources/serialization_baseline/collections/main/queries/metric__measure__and_segment_references.yaml index 8e24c3264065..3beff8e003d3 100644 --- a/test_resources/serialization_baseline/collections/main/queries/metric__measure__and_segment_references.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/metric__measure__and_segment_references.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/native_query___card_and_snippet_references.yaml b/test_resources/serialization_baseline/collections/main/queries/native_query___card_and_snippet_references.yaml index 8158c41b90be..7f7d18e24421 100644 --- a/test_resources/serialization_baseline/collections/main/queries/native_query___card_and_snippet_references.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/native_query___card_and_snippet_references.yaml @@ -4,8 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: native -database_id: Sample Database parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/native_query___field_filter_and_temporal_unit.yaml b/test_resources/serialization_baseline/collections/main/queries/native_query___field_filter_and_temporal_unit.yaml index 46efd41239e6..2efd61d6b597 100644 --- a/test_resources/serialization_baseline/collections/main/queries/native_query___field_filter_and_temporal_unit.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/native_query___field_filter_and_temporal_unit.yaml @@ -4,8 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: native -database_id: Sample Database parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/native_query___variables.yaml b/test_resources/serialization_baseline/collections/main/queries/native_query___variables.yaml index db29bded0cf8..09201f93c4dc 100644 --- a/test_resources/serialization_baseline/collections/main/queries/native_query___variables.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/native_query___variables.yaml @@ -4,8 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: native -database_id: Sample Database parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/nested_query.yaml b/test_resources/serialization_baseline/collections/main/queries/nested_query.yaml index 1625a198a831..c9daaf971015 100644 --- a/test_resources/serialization_baseline/collections/main/queries/nested_query.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/nested_query.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/null__empty__and_string_filters.yaml b/test_resources/serialization_baseline/collections/main/queries/null__empty__and_string_filters.yaml index dc4455c8d5f1..dc29c9f62675 100644 --- a/test_resources/serialization_baseline/collections/main/queries/null__empty__and_string_filters.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/null__empty__and_string_filters.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/source_card_reference.yaml b/test_resources/serialization_baseline/collections/main/queries/source_card_reference.yaml index 22aa80754dee..731b1a179286 100644 --- a/test_resources/serialization_baseline/collections/main/queries/source_card_reference.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/source_card_reference.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: @@ -218,5 +212,4 @@ serdes/meta: label: source_card_reference model: Card metabase_version: v1.57.1-SNAPSHOT (14ff27d) -source_card_id: h5F2EjHsRd73Dqqh8sAtd type: question diff --git a/test_resources/serialization_baseline/collections/main/queries/string_functions.yaml b/test_resources/serialization_baseline/collections/main/queries/string_functions.yaml index 96e45b7c7fa2..a39b5a1f17f1 100644 --- a/test_resources/serialization_baseline/collections/main/queries/string_functions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/string_functions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/temporal_and_logical_filters.yaml b/test_resources/serialization_baseline/collections/main/queries/temporal_and_logical_filters.yaml index 5018dc02355a..fe5691078c93 100644 --- a/test_resources/serialization_baseline/collections/main/queries/temporal_and_logical_filters.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/temporal_and_logical_filters.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/temporal_expressions.yaml b/test_resources/serialization_baseline/collections/main/queries/temporal_expressions.yaml index 4a0c348a4210..841675e0bcff 100644 --- a/test_resources/serialization_baseline/collections/main/queries/temporal_expressions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/temporal_expressions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/temporal_extraction.yaml b/test_resources/serialization_baseline/collections/main/queries/temporal_extraction.yaml index 5f69811546ee..07791d5c9d5f 100644 --- a/test_resources/serialization_baseline/collections/main/queries/temporal_extraction.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/temporal_extraction.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/total_revenue.yaml b/test_resources/serialization_baseline/collections/main/queries/total_revenue.yaml index 18d44adc7665..be6eef235468 100644 --- a/test_resources/serialization_baseline/collections/main/queries/total_revenue.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/total_revenue.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: scalar collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/url_functions.yaml b/test_resources/serialization_baseline/collections/main/queries/url_functions.yaml index 7ec4505a7086..5eb905b4ab3f 100644 --- a/test_resources/serialization_baseline/collections/main/queries/url_functions.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/url_functions.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PEOPLE parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/queries/window_function___offset.yaml b/test_resources/serialization_baseline/collections/main/queries/window_function___offset.yaml index dc5c10422797..f291a0d404ab 100644 --- a/test_resources/serialization_baseline/collections/main/queries/window_function___offset.yaml +++ b/test_resources/serialization_baseline/collections/main/queries/window_function___offset.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlQuErIeS0ExAmPlE2x1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/source_model.yaml b/test_resources/serialization_baseline/collections/main/source_model.yaml index 634f62d506ba..bec09eb88a76 100644 --- a/test_resources/serialization_baseline/collections/main/source_model.yaml +++ b/test_resources/serialization_baseline/collections/main/source_model.yaml @@ -3,12 +3,6 @@ entity_id: sRcMoDeL00ExAmPlEx001 created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/customer_locations__map_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/customer_locations__map_.yaml index 61c09687a79b..747e74eef9fa 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/customer_locations__map_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/customer_locations__map_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: map collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PEOPLE parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/monthly_revenue_changes__waterfall_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/monthly_revenue_changes__waterfall_.yaml index 7465bb08a303..7f0859ec4e50 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/monthly_revenue_changes__waterfall_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/monthly_revenue_changes__waterfall_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: waterfall collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__pie_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__pie_.yaml index 934d79b939a2..1ff1a7626dad 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__pie_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__pie_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: pie collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__stacked_bar_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__stacked_bar_.yaml index 73bb2dd9a235..9e5392ae1888 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__stacked_bar_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_by_category__stacked_bar_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: bar collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_pivot_table.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_pivot_table.yaml index f1eee0d88bc6..b9d5c0af8d4d 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/orders_pivot_table.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/orders_pivot_table.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: pivot collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/products_table__formatted_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/products_table__formatted_.yaml index c99f47a5b7db..02f48ea1e5c7 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/products_table__formatted_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/products_table__formatted_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: table collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_gauge.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_gauge.yaml index 8de72cab9b6e..54aa69aff9b9 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_gauge.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_gauge.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: gauge collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_over_time__line_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_over_time__line_.yaml index 1e67546bd855..f8ec408fe4e8 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_over_time__line_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/revenue_over_time__line_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: line collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/sales_funnel.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/sales_funnel.yaml index 1fbb69ce1287..fc87ee61cf84 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/sales_funnel.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/sales_funnel.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: funnel collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- PRODUCTS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/collections/main/visualization_settings/total_revenue__smart_scalar_.yaml b/test_resources/serialization_baseline/collections/main/visualization_settings/total_revenue__smart_scalar_.yaml index 239e64a9f429..46b08f7a0408 100644 --- a/test_resources/serialization_baseline/collections/main/visualization_settings/total_revenue__smart_scalar_.yaml +++ b/test_resources/serialization_baseline/collections/main/visualization_settings/total_revenue__smart_scalar_.yaml @@ -4,12 +4,6 @@ created_at: '2026-04-02T02:59:02.778668Z' creator_id: admin@example.com display: smartscalar collection_id: cOlViZsEtTiNgS0ExAmP1 -query_type: query -database_id: Sample Database -table_id: -- Sample Database -- PUBLIC -- ORDERS parameters: [] parameter_mappings: [] dataset_query: diff --git a/test_resources/serialization_baseline/databases/sample_database/sample_database.yaml b/test_resources/serialization_baseline/databases/sample_database/sample_database.yaml index 33119db42750..a5da8667b03b 100644 --- a/test_resources/serialization_baseline/databases/sample_database/sample_database.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/sample_database.yaml @@ -1,11 +1,11 @@ name: Sample Database -engine: h2 +engine: postgres dbms_version: - flavor: H2 - version: 2.1.214 (2022-06-13) + flavor: PostgreSQL + version: '14.0' semantic-version: - - 2 - - 1 + - 14 + - 0 created_at: '2024-08-28T14:38:42.764234Z' timezone: UTC metadata_sync_schedule: 0 22 * * * ? * diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/average_revenue_per_order.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/average_revenue_per_order.yaml index 0e8d1ea2dc2a..8396a51f6139 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/average_revenue_per_order.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/average_revenue_per_order.yaml @@ -21,10 +21,6 @@ definition: description: Uses Total Revenue and Order Count measures entity_id: dc8eiyLh9S0IKwV_UuvTz name: Average Revenue Per Order -table_id: -- Sample Database -- PUBLIC -- ORDERS serdes/meta: - id: dc8eiyLh9S0IKwV_UuvTz label: average_revenue_per_order diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/median_order_total.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/median_order_total.yaml index a88b81aaf3dd..54265284673c 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/median_order_total.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/median_order_total.yaml @@ -20,10 +20,6 @@ definition: lib/type: mbql/query entity_id: d4G5fMj5smJ17Nd0dvE2A name: Median Order Total -table_id: -- Sample Database -- PUBLIC -- ORDERS serdes/meta: - id: d4G5fMj5smJ17Nd0dvE2A label: median_order_total diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/order_count.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/order_count.yaml index a36ac1312247..90854cc0b282 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/order_count.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/order_count.yaml @@ -14,10 +14,6 @@ definition: lib/type: mbql/query entity_id: DBap2523KuN4Lt-cYrUjF name: Order Count -table_id: -- Sample Database -- PUBLIC -- ORDERS serdes/meta: - id: DBap2523KuN4Lt-cYrUjF label: order_count diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/total_revenue.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/total_revenue.yaml index 943c605433cd..63f504d8181a 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/total_revenue.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/measures/total_revenue.yaml @@ -20,10 +20,6 @@ definition: lib/type: mbql/query entity_id: 8WteUpyQQqObrxLv4KQ-j name: Total Revenue -table_id: -- Sample Database -- PUBLIC -- ORDERS serdes/meta: - id: 8WteUpyQQqObrxLv4KQ-j label: total_revenue diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/segments/large_orders.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/segments/large_orders.yaml index 94ef632c5557..3197c894278b 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/segments/large_orders.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/orders/segments/large_orders.yaml @@ -21,10 +21,6 @@ definition: lib/type: mbql/query entity_id: OaFXAvEzPvu9ZXW5PUMRa name: Large Orders -table_id: -- Sample Database -- PUBLIC -- ORDERS serdes/meta: - id: OaFXAvEzPvu9ZXW5PUMRa label: large_orders diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/people/segments/nyc_area_people.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/people/segments/nyc_area_people.yaml index 3548c53c66b1..163a69e42ea8 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/people/segments/nyc_area_people.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/people/segments/nyc_area_people.yaml @@ -31,10 +31,6 @@ definition: description: People located within the NYC bounding box entity_id: D2R9JpvI8sMfYWqfPsTkx name: NYC Area People -table_id: -- Sample Database -- PUBLIC -- PEOPLE serdes/meta: - id: D2R9JpvI8sMfYWqfPsTkx label: nyc_area_people diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price.yaml index c0f0fb4211d9..5f91fbd0e118 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price.yaml @@ -20,10 +20,6 @@ definition: lib/type: mbql/query entity_id: xK7mPqR2sT4uVwXyZ9a1b name: Average Product Price -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: xK7mPqR2sT4uVwXyZ9a1b label: average_product_price diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price_2.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price_2.yaml index e3e71b2acc50..2a12e6be1f0e 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price_2.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_product_price_2.yaml @@ -20,10 +20,6 @@ definition: lib/type: mbql/query entity_id: mSrAvgProdPrice00008x name: Average Product Price -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: mSrAvgProdPrice00008x label: average_product_price diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_widget_price.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_widget_price.yaml index 50d610a421f2..438f115ede4b 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_widget_price.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/average_widget_price.yaml @@ -21,10 +21,6 @@ definition: description: Average price of Widget products — uses Widget Products segment and Average Product Price measure entity_id: K7cMzOau9SI2h8hCx936w name: Average Widget Price -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: K7cMzOau9SI2h8hCx936w label: average_widget_price diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/product_count.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/product_count.yaml index ec4d593dfb95..5753135109f5 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/product_count.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/measures/product_count.yaml @@ -20,10 +20,6 @@ definition: lib/type: mbql/query entity_id: 8kxQ2Wr7PmN5dLf3hYz9v name: Product Count -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: 8kxQ2Wr7PmN5dLf3hYz9v label: product_count diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/premium_widgets.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/premium_widgets.yaml index d6d42e7b567f..12aba54d39ac 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/premium_widgets.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/premium_widgets.yaml @@ -25,10 +25,6 @@ definition: description: Widget products priced above $50 — refines the Widget Products segment entity_id: 2hxhoN3HrQJIaWc5umpBn name: Premium Widgets -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: 2hxhoN3HrQJIaWc5umpBn label: premium_widgets diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/recent_mid_range_products.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/recent_mid_range_products.yaml index de1e9b270862..cb18bbd257bc 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/recent_mid_range_products.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/recent_mid_range_products.yaml @@ -51,10 +51,6 @@ definition: description: Products created in last year, priced 20-100, not null rating entity_id: wdYkSSArR5ymVGa0pvb47 name: Recent Mid-Range Products -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: wdYkSSArR5ymVGa0pvb47 label: recent_mid_range_products diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/searchable_products.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/searchable_products.yaml index 94e373c6094a..4f2593894b2f 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/searchable_products.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/searchable_products.yaml @@ -57,10 +57,6 @@ definition: description: Products with non-empty title containing "awesome", vendor starting with "A" entity_id: yZptbJRR3S8L9S9r0ZF8M name: Searchable Products -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: yZptbJRR3S8L9S9r0ZF8M label: searchable_products diff --git a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/widget_products.yaml b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/widget_products.yaml index bfba5d34bdfa..1075d7a3e644 100644 --- a/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/widget_products.yaml +++ b/test_resources/serialization_baseline/databases/sample_database/schemas/public/tables/products/segments/widget_products.yaml @@ -21,10 +21,6 @@ definition: lib/type: mbql/query entity_id: aB3kLmN9pQrStUvWxYz1a name: Widget Products -table_id: -- Sample Database -- PUBLIC -- PRODUCTS serdes/meta: - id: aB3kLmN9pQrStUvWxYz1a label: widget_products diff --git a/test_resources/serialization_baseline/settings.yaml b/test_resources/serialization_baseline/settings.yaml index 715d38b7b330..d02946fb8aa6 100644 --- a/test_resources/serialization_baseline/settings.yaml +++ b/test_resources/serialization_baseline/settings.yaml @@ -1,6 +1,7 @@ agent-api-enabled?: null aggregated-query-row-limit: null ai-features-enabled?: null +ai-usage-max-retention-days: null allowed-iframe-hosts: null analytics-pii-retention-enabled: null application-colors: null